tournament outputs
This commit is contained in:
parent
bf21a5eae6
commit
92dddca964
@ -1,11 +1,25 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
from accounts.models import CustomUser
|
from accounts.models import CustomUser
|
||||||
from animations.schemas import RankingSchema
|
from animations.schemas import (
|
||||||
from submissions.models import PuzzleResponse, SteamCollectionItem
|
RankingSchema,
|
||||||
|
TournamentWinnersOut,
|
||||||
|
TournamentSubmissionsOut,
|
||||||
|
TournamentPuzzleResultsOut,
|
||||||
|
PuzzleWinnerOut,
|
||||||
|
PuzzleSubmissionsOut,
|
||||||
|
PuzzleResultsOut,
|
||||||
|
PuzzleSubmissionWithRankOut,
|
||||||
|
PuzzlePointsFactorOut,
|
||||||
|
WinnerResponseOut,
|
||||||
|
WinnerFileOut,
|
||||||
|
)
|
||||||
|
from submissions.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@ -46,3 +60,218 @@ def results(request: HttpRequest) -> dict:
|
|||||||
|
|
||||||
cache.set("api:results:results", data, 300)
|
cache.set("api:results:results", data, 300)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("winners", response=TournamentWinnersOut)
|
||||||
|
def winners(request: HttpRequest) -> TournamentWinnersOut:
|
||||||
|
"""Get tournament winners with submission file URLs. Only available when tournament is closed."""
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by("order_index")
|
||||||
|
|
||||||
|
# Get best response for each puzzle
|
||||||
|
winners_by_puzzle = {}
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the best response for this puzzle (ranked by points)
|
||||||
|
best_response = (
|
||||||
|
PuzzleResponse.objects
|
||||||
|
.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("rank_points")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if best_response:
|
||||||
|
winners_by_puzzle[puzzle.id] = best_response
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
winners_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
best_response = winners_by_puzzle.get(puzzle.id)
|
||||||
|
|
||||||
|
winner_data = None
|
||||||
|
if best_response:
|
||||||
|
# Get submission files
|
||||||
|
files = best_response.files.all()
|
||||||
|
winner_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
winner_data = WinnerResponseOut(
|
||||||
|
user_id=best_response.submission.user.id if best_response.submission.user else 0,
|
||||||
|
username=best_response.submission.user.username if best_response.submission.user else "Anonymous",
|
||||||
|
final_cost=best_response.final_cost,
|
||||||
|
final_cycles=best_response.final_cycles,
|
||||||
|
final_area=best_response.final_area,
|
||||||
|
rank_points=best_response.rank_points,
|
||||||
|
files=winner_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
winners_list.append(
|
||||||
|
PuzzleWinnerOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
winner=winner_data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return TournamentWinnersOut(winners=winners_list)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("top-submissions", response=TournamentSubmissionsOut)
|
||||||
|
def top_submissions(request: HttpRequest, limit: int = 5) -> TournamentSubmissionsOut:
|
||||||
|
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by("order_index")
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
submissions_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects
|
||||||
|
.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle
|
||||||
|
puzzle_submissions = []
|
||||||
|
for response in top_responses:
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if puzzle.points_factor and response.final_cost is not None and response.final_cycles is not None and response.final_area is not None:
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost +
|
||||||
|
puzzle.points_factor.cycles * response.final_cycles +
|
||||||
|
puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = WinnerResponseOut(
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username if response.submission.user else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
submissions_list.append(
|
||||||
|
PuzzleSubmissionsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return TournamentSubmissionsOut(submissions=submissions_list)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
|
||||||
|
def puzzle_results(request: HttpRequest, limit: int = 5) -> TournamentPuzzleResultsOut:
|
||||||
|
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by("order_index")
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
results_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects
|
||||||
|
.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle with rank
|
||||||
|
puzzle_submissions = []
|
||||||
|
for rank, response in enumerate(top_responses, 1):
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if puzzle.points_factor and response.final_cost is not None and response.final_cycles is not None and response.final_area is not None:
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost +
|
||||||
|
puzzle.points_factor.cycles * response.final_cycles +
|
||||||
|
puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = PuzzleSubmissionWithRankOut(
|
||||||
|
rank=rank,
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username if response.submission.user else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
# Get points factor if available
|
||||||
|
points_factor = None
|
||||||
|
if puzzle.points_factor:
|
||||||
|
points_factor = PuzzlePointsFactorOut(
|
||||||
|
cost=puzzle.points_factor.cost,
|
||||||
|
cycles=puzzle.points_factor.cycles,
|
||||||
|
area=puzzle.points_factor.area,
|
||||||
|
)
|
||||||
|
|
||||||
|
results_list.append(
|
||||||
|
PuzzleResultsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
points_factor=points_factor,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return TournamentPuzzleResultsOut(results=results_list)
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from ninja import ModelSchema, Schema
|
from ninja import ModelSchema, Schema
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
from submissions.models import PuzzleResponse
|
from submissions.models import PuzzleResponse
|
||||||
from submissions.schemas import SteamCollectionItemOut
|
from submissions.schemas import SteamCollectionItemOut
|
||||||
@ -41,3 +42,88 @@ class RankingSchema(Schema):
|
|||||||
puzzles: list[SteamCollectionItemOut]
|
puzzles: list[SteamCollectionItemOut]
|
||||||
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerFileOut(Schema):
|
||||||
|
"""Schema for winner submission file"""
|
||||||
|
|
||||||
|
file_url: str
|
||||||
|
original_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerResponseOut(Schema):
|
||||||
|
"""Schema for winner response with files"""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleWinnerOut(Schema):
|
||||||
|
"""Schema for puzzle with winner info"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
winner: Optional[WinnerResponseOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionsOut(Schema):
|
||||||
|
"""Schema for puzzle with all top submissions"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
submissions: List[WinnerResponseOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentWinnersOut(Schema):
|
||||||
|
"""Schema for tournament winners results"""
|
||||||
|
|
||||||
|
winners: List[PuzzleWinnerOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentSubmissionsOut(Schema):
|
||||||
|
"""Schema for tournament top submissions results"""
|
||||||
|
|
||||||
|
submissions: List[PuzzleSubmissionsOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsFactorOut(Schema):
|
||||||
|
"""Schema for puzzle points factor"""
|
||||||
|
|
||||||
|
cost: int
|
||||||
|
cycles: int
|
||||||
|
area: int
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionWithRankOut(Schema):
|
||||||
|
"""Schema for puzzle submission with rank"""
|
||||||
|
|
||||||
|
rank: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleResultsOut(Schema):
|
||||||
|
"""Schema for puzzle-specific results with coefficients"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
points_factor: Optional[PuzzlePointsFactorOut]
|
||||||
|
submissions: List[PuzzleSubmissionWithRankOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentPuzzleResultsOut(Schema):
|
||||||
|
"""Schema for tournament puzzle-specific results"""
|
||||||
|
|
||||||
|
results: List[PuzzleResultsOut]
|
||||||
|
|||||||
@ -4,10 +4,12 @@ import PuzzleCard from "@/components/PuzzleCard.vue";
|
|||||||
import SubmissionForm from "@/components/SubmissionForm.vue";
|
import SubmissionForm from "@/components/SubmissionForm.vue";
|
||||||
import AdminPanel from "@/components/AdminPanel.vue";
|
import AdminPanel from "@/components/AdminPanel.vue";
|
||||||
import Results from "@/components/Results.vue";
|
import Results from "@/components/Results.vue";
|
||||||
|
import Winners from "@/components/Winners.vue";
|
||||||
|
import PuzzleResults from "@/components/PuzzleResults.vue";
|
||||||
import { apiService, errorHelpers } from "@/services/apiService";
|
import { apiService, errorHelpers } from "@/services/apiService";
|
||||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||||
import { useSubmissionsStore } from "@/stores/submissions";
|
import { useSubmissionsStore } from "@/stores/submissions";
|
||||||
import type { PuzzleResponse, UserInfo } from "@/types";
|
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
|
||||||
import { useCountdown } from "@vueuse/core";
|
import { useCountdown } from "@vueuse/core";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
@ -26,6 +28,7 @@ const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
|
|||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const userInfo = ref<UserInfo | null>(null);
|
const userInfo = ref<UserInfo | null>(null);
|
||||||
|
const collection = ref<SteamCollection | null>(null);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const error = ref<string>("");
|
const error = ref<string>("");
|
||||||
|
|
||||||
@ -34,6 +37,10 @@ const isSuperuser = computed(() => {
|
|||||||
return userInfo.value?.is_superuser || false;
|
return userInfo.value?.is_superuser || false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isTournamentClosed = computed(() => {
|
||||||
|
return !!(collection.value && !collection.value.accepting_submissions);
|
||||||
|
});
|
||||||
|
|
||||||
// Computed property to get responses grouped by puzzle
|
// Computed property to get responses grouped by puzzle
|
||||||
const responsesByPuzzle = computed(() => {
|
const responsesByPuzzle = computed(() => {
|
||||||
const grouped: Record<number, PuzzleResponse[]> = {};
|
const grouped: Record<number, PuzzleResponse[]> = {};
|
||||||
@ -66,6 +73,16 @@ async function initialize() {
|
|||||||
console.warn("User info error:", userResponse.error);
|
console.warn("User info error:", userResponse.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load collection data
|
||||||
|
console.log("Loading collection...");
|
||||||
|
const collectionResponse = await apiService.getCollection();
|
||||||
|
if (collectionResponse.data) {
|
||||||
|
collection.value = collectionResponse.data;
|
||||||
|
console.log("Collection loaded:", collectionResponse.data);
|
||||||
|
} else if (collectionResponse.error) {
|
||||||
|
console.warn("Collection error:", collectionResponse.error);
|
||||||
|
}
|
||||||
|
|
||||||
// Load puzzles from API using store
|
// Load puzzles from API using store
|
||||||
console.log("Loading puzzles...");
|
console.log("Loading puzzles...");
|
||||||
await puzzlesStore.loadPuzzles();
|
await puzzlesStore.loadPuzzles();
|
||||||
@ -173,6 +190,15 @@ const goHome = () => {
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div v-else class="space-y-8">
|
<div v-else class="space-y-8">
|
||||||
|
|
||||||
|
<!-- Winners Section (only when tournament is closed) -->
|
||||||
|
<div v-if="isTournamentClosed" class="space-y-8">
|
||||||
|
<Winners />
|
||||||
|
<PuzzleResults />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
<!-- Collection Info -->
|
<!-- Collection Info -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-lg">
|
||||||
@ -182,11 +208,18 @@ const goHome = () => {
|
|||||||
{{ props.collectionDescription }}
|
{{ props.collectionDescription }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap gap-4 mt-4">
|
<div class="flex flex-wrap gap-4 mt-4">
|
||||||
<button @click="openSubmissionModal" class="btn btn-primary">
|
<button @click="openSubmissionModal" class="btn btn-primary" :disabled="isTournamentClosed">
|
||||||
<i class="mdi mdi-plus mr-2"></i>
|
<i class="mdi mdi-plus mr-2"></i>
|
||||||
Submit Solution
|
Submit Solution
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isTournamentClosed" class="alert alert-warning mt-4">
|
||||||
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold">Tournament Closed</h3>
|
||||||
|
<div class="text-sm">This tournament is no longer accepting new submissions.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -212,6 +245,7 @@ const goHome = () => {
|
|||||||
Check back later for new puzzle collections!
|
Check back later for new puzzle collections!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
184
polylan_submitter/src/components/PuzzleResults.vue
Normal file
184
polylan_submitter/src/components/PuzzleResults.vue
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { apiService } from "@/services/apiService";
|
||||||
|
import type { TournamentPuzzleResults } from "@/types";
|
||||||
|
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const resultsData = ref<TournamentPuzzleResults | null>(null);
|
||||||
|
const error = ref<string>("");
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const showImageModal = ref(false);
|
||||||
|
const selectedImageUrl = ref<string>("");
|
||||||
|
const selectedImageName = ref<string>("");
|
||||||
|
|
||||||
|
const fetchResults = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const response = await apiService.getPuzzleResults(5);
|
||||||
|
if (response.data) {
|
||||||
|
resultsData.value = response.data;
|
||||||
|
} else if (response.error) {
|
||||||
|
error.value = response.error;
|
||||||
|
console.error("Error fetching results:", response.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : "Failed to fetch results";
|
||||||
|
console.error("Error fetching results:", err);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number | undefined) => {
|
||||||
|
return num !== undefined ? num.toLocaleString() : "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
const openImageModal = (fileUrl: string, fileName: string) => {
|
||||||
|
selectedImageUrl.value = fileUrl;
|
||||||
|
selectedImageName.value = fileName;
|
||||||
|
showImageModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeImageModal = () => {
|
||||||
|
showImageModal.value = false;
|
||||||
|
selectedImageUrl.value = "";
|
||||||
|
selectedImageName.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRankBadge = (rank: number) => {
|
||||||
|
const badges = ["🥇", "🥈", "🥉"];
|
||||||
|
return badges[rank - 1] || `#${rank}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchResults();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-table text-blue-500 text-3xl"></i>
|
||||||
|
Results by Puzzle
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="flex justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="alert alert-error">
|
||||||
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
|
<div>{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!resultsData || resultsData.results.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-base-content/70">No results available yet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-8">
|
||||||
|
<div v-for="puzzle in resultsData.results" :key="puzzle.puzzle_id" class="border-b pb-8 last:border-b-0">
|
||||||
|
<!-- Puzzle Header with Coefficients -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="bg-base-200 p-3 rounded-lg mb-4" v-if="puzzle.points_factor">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Results Table -->
|
||||||
|
<div v-if="puzzle.submissions.length > 0" class="overflow-x-auto">
|
||||||
|
<table class="table table-sm table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="w-12 text-center">Pos</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th class="text-right">Cost</th>
|
||||||
|
<th class="text-right">Cycles</th>
|
||||||
|
<th class="text-right">Area</th>
|
||||||
|
<th class="text-right font-bold">Total Pts</th>
|
||||||
|
<th class="text-right font-bold">Total Coef</th>
|
||||||
|
<th class="text-center">GIF</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="submission in puzzle.submissions" :key="`${puzzle.puzzle_id}-${submission.user_id}`">
|
||||||
|
<td class="text-center text-lg font-bold">
|
||||||
|
{{ getRankBadge(submission.rank) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-semibold">{{ submission.username }}</td>
|
||||||
|
<td class="text-right">{{ formatNumber(submission.final_cost) }}</td>
|
||||||
|
<td class="text-right">{{ formatNumber(submission.final_cycles) }}</td>
|
||||||
|
<td class="text-right">{{ formatNumber(submission.final_area) }}</td>
|
||||||
|
<td class="text-right font-bold" :class="{
|
||||||
|
'text-yellow-600': submission.rank === 1,
|
||||||
|
'text-gray-600': submission.rank === 2,
|
||||||
|
'text-orange-600': submission.rank === 3,
|
||||||
|
}">
|
||||||
|
{{ formatNumber(submission.rank_points) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-bold text-primary">
|
||||||
|
{{ formatNumber(submission.total_coef) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button v-if="submission.files.length > 0"
|
||||||
|
@click="openImageModal(submission.files[0].file_url, submission.files[0].original_filename)"
|
||||||
|
class="btn btn-xs btn-primary gap-1">
|
||||||
|
<i class="mdi mdi-image"></i>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-base-content/50">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="p-4 bg-base-200 rounded-lg text-center text-base-content/70">
|
||||||
|
No submissions yet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Modal -->
|
||||||
|
<div v-if="showImageModal" class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-7xl w-full">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-bold text-lg">{{ selectedImageName }}</h3>
|
||||||
|
<button @click="closeImageModal" class="btn btn-sm btn-circle btn-ghost">
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center bg-base-200 rounded-lg p-4">
|
||||||
|
<img :src="selectedImageUrl" :alt="selectedImageName" class="object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action mt-4">
|
||||||
|
<a :href="selectedImageUrl" target="_blank" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-download"></i>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button @click="closeImageModal" class="btn btn-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" @click="closeImageModal"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
171
polylan_submitter/src/components/Winners.vue
Normal file
171
polylan_submitter/src/components/Winners.vue
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
import { apiService } from "@/services/apiService";
|
||||||
|
import type { TournamentSubmissions, WinnerResponse, PuzzleSubmissions } from "@/types";
|
||||||
|
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const submissionsData = ref<TournamentSubmissions | null>(null);
|
||||||
|
const error = ref<string>("");
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const showImageModal = ref(false);
|
||||||
|
const selectedImageUrl = ref<string>("");
|
||||||
|
const selectedImageName = ref<string>("");
|
||||||
|
|
||||||
|
const fetchSubmissions = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const response = await apiService.getTopSubmissions(5);
|
||||||
|
if (response.data) {
|
||||||
|
submissionsData.value = response.data;
|
||||||
|
} else if (response.error) {
|
||||||
|
error.value = response.error;
|
||||||
|
console.error("Error fetching submissions:", response.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : "Failed to fetch submissions";
|
||||||
|
console.error("Error fetching submissions:", err);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num: number | undefined) => {
|
||||||
|
return num !== undefined ? num.toLocaleString() : "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flatten all submissions into a single table
|
||||||
|
const flattenedRows = computed(() => {
|
||||||
|
if (!submissionsData.value) return [];
|
||||||
|
|
||||||
|
const rows: Array<{
|
||||||
|
puzzleName: string;
|
||||||
|
username: string;
|
||||||
|
cost: number | undefined;
|
||||||
|
cycles: number | undefined;
|
||||||
|
area: number | undefined;
|
||||||
|
total: number | undefined;
|
||||||
|
totalCoef: number | undefined;
|
||||||
|
files: Array<{ url: string; name: string }>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
submissionsData.value.submissions.forEach((puzzle: PuzzleSubmissions) => {
|
||||||
|
puzzle.submissions.forEach((submission: WinnerResponse) => {
|
||||||
|
rows.push({
|
||||||
|
puzzleName: puzzle.puzzle_title,
|
||||||
|
username: submission.username,
|
||||||
|
cost: submission.final_cost,
|
||||||
|
cycles: submission.final_cycles,
|
||||||
|
area: submission.final_area,
|
||||||
|
total: submission.rank_points,
|
||||||
|
totalCoef: submission.total_coef,
|
||||||
|
files: submission.files.map(f => ({ url: f.file_url, name: f.original_filename })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openImageModal = (fileUrl: string, fileName: string) => {
|
||||||
|
selectedImageUrl.value = fileUrl;
|
||||||
|
selectedImageName.value = fileName;
|
||||||
|
showImageModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeImageModal = () => {
|
||||||
|
showImageModal.value = false;
|
||||||
|
selectedImageUrl.value = "";
|
||||||
|
selectedImageName.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSubmissions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card bg-base-100 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-trophy text-yellow-500 text-3xl"></i>
|
||||||
|
Top Submissions
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="flex justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="alert alert-error">
|
||||||
|
<i class="mdi mdi-alert-circle text-xl"></i>
|
||||||
|
<div>{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="flattenedRows.length === 0" 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">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Puzzle</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th class="text-right">Cost</th>
|
||||||
|
<th class="text-right">Cycles</th>
|
||||||
|
<th class="text-right">Area</th>
|
||||||
|
<th class="text-right">Total Pts</th>
|
||||||
|
<th class="text-right">Total Coef</th>
|
||||||
|
<th class="text-center">GIF</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in flattenedRows" :key="index">
|
||||||
|
<td class="font-semibold">{{ row.puzzleName }}</td>
|
||||||
|
<td>{{ row.username }}</td>
|
||||||
|
<td class="text-right">{{ formatNumber(row.cost) }}</td>
|
||||||
|
<td class="text-right">{{ formatNumber(row.cycles) }}</td>
|
||||||
|
<td class="text-right">{{ formatNumber(row.area) }}</td>
|
||||||
|
<td class="text-right font-bold">{{ formatNumber(row.total) }}</td>
|
||||||
|
<td class="text-right font-bold text-primary">{{ formatNumber(row.totalCoef) }}</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<button v-if="row.files.length > 0" @click="openImageModal(row.files[0].url, row.files[0].name)"
|
||||||
|
class="btn btn-xs btn-primary gap-1">
|
||||||
|
<i class="mdi mdi-image"></i>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-base-content/50">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Modal -->
|
||||||
|
<div v-if="showImageModal" class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-7xl w-full">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-bold text-lg">{{ selectedImageName }}</h3>
|
||||||
|
<button @click="closeImageModal" class="btn btn-sm btn-circle btn-ghost">
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center bg-base-200 rounded-lg p-4">
|
||||||
|
<img :src="selectedImageUrl" :alt="selectedImageName" class="object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action mt-4">
|
||||||
|
<a :href="selectedImageUrl" target="_blank" class="btn btn-primary btn-sm">
|
||||||
|
<i class="mdi mdi-download"></i>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button @click="closeImageModal" class="btn btn-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" @click="closeImageModal"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,9 +1,13 @@
|
|||||||
import type {
|
import type {
|
||||||
|
SteamCollection,
|
||||||
SteamCollectionItem,
|
SteamCollectionItem,
|
||||||
Submission,
|
Submission,
|
||||||
PuzzleResponse,
|
PuzzleResponse,
|
||||||
SubmissionFile,
|
SubmissionFile,
|
||||||
UserInfo
|
UserInfo,
|
||||||
|
TournamentWinners,
|
||||||
|
TournamentSubmissions,
|
||||||
|
TournamentPuzzleResults
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
// API Configuration
|
// API Configuration
|
||||||
@ -101,6 +105,22 @@ export class ApiService {
|
|||||||
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
|
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCollection(): Promise<ApiResponse<SteamCollection>> {
|
||||||
|
return this.request<SteamCollection>('/submissions/collection')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWinners(): Promise<ApiResponse<TournamentWinners>> {
|
||||||
|
return this.request<TournamentWinners>('/results/winners')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopSubmissions(limit = 5): Promise<ApiResponse<TournamentSubmissions>> {
|
||||||
|
return this.request<TournamentSubmissions>(`/results/top-submissions?limit=${limit}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPuzzleResults(limit = 5): Promise<ApiResponse<TournamentPuzzleResults>> {
|
||||||
|
return this.request<TournamentPuzzleResults>(`/results/puzzle-results?limit=${limit}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Submission endpoints
|
// Submission endpoints
|
||||||
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
|
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
|
||||||
return this.request<PaginatedResponse<Submission>>(
|
return this.request<PaginatedResponse<Submission>>(
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export interface SteamCollection {
|
|||||||
total_items: number
|
total_items: number
|
||||||
unique_visitors: number
|
unique_visitors: number
|
||||||
current_favorites: number
|
current_favorites: number
|
||||||
|
accepting_submissions: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -107,3 +108,68 @@ export interface UserInfo {
|
|||||||
is_superuser: boolean
|
is_superuser: boolean
|
||||||
cas_groups?: string[]
|
cas_groups?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WinnerFile {
|
||||||
|
file_url: string
|
||||||
|
original_filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WinnerResponse {
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
final_cost?: number
|
||||||
|
final_cycles?: number
|
||||||
|
final_area?: number
|
||||||
|
rank_points?: number
|
||||||
|
total_coef?: number
|
||||||
|
files: WinnerFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleWinner {
|
||||||
|
puzzle_id: number
|
||||||
|
puzzle_title: string
|
||||||
|
winner?: WinnerResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentWinners {
|
||||||
|
winners: PuzzleWinner[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleSubmissions {
|
||||||
|
puzzle_id: number
|
||||||
|
puzzle_title: string
|
||||||
|
submissions: WinnerResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentSubmissions {
|
||||||
|
submissions: PuzzleSubmissions[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzlePointsFactor {
|
||||||
|
cost: number
|
||||||
|
cycles: number
|
||||||
|
area: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleSubmissionWithRank {
|
||||||
|
rank: number
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
final_cost?: number
|
||||||
|
final_cycles?: number
|
||||||
|
final_area?: number
|
||||||
|
rank_points?: number
|
||||||
|
total_coef?: number
|
||||||
|
files: WinnerFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleResults {
|
||||||
|
puzzle_id: number
|
||||||
|
puzzle_title: string
|
||||||
|
points_factor?: PuzzlePointsFactor
|
||||||
|
submissions: PuzzleSubmissionWithRank[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentPuzzleResults {
|
||||||
|
results: PuzzleResults[]
|
||||||
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
import{k as t,l as a,p as n,v as s}from"./style-CufywNmO.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 _};
|
import{k as t,l as a,p as n,v as s}from"./style-C433w8gz.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 +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-CufywNmO.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);
|
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-C433w8gz.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
@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"_RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js": {
|
"_RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js": {
|
||||||
"file": "assets/RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js",
|
"file": "assets/RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js",
|
||||||
"name": "RankBadge.vue_vue_type_script_setup_true_lang",
|
"name": "RankBadge.vue_vue_type_script_setup_true_lang",
|
||||||
"imports": [
|
"imports": [
|
||||||
"_style-CufywNmO.js"
|
"_style-C433w8gz.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_style-CufywNmO.js": {
|
"_style-C433w8gz.js": {
|
||||||
"file": "assets/style-CufywNmO.js",
|
"file": "assets/style-C433w8gz.js",
|
||||||
"name": "style",
|
"name": "style",
|
||||||
"css": [
|
"css": [
|
||||||
"assets/style-DaHD49X0.css"
|
"assets/style-CMHHCLN4.css"
|
||||||
],
|
],
|
||||||
"assets": [
|
"assets": [
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||||
@ -19,9 +19,9 @@
|
|||||||
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"_style-DaHD49X0.css": {
|
"_style-CMHHCLN4.css": {
|
||||||
"file": "assets/style-DaHD49X0.css",
|
"file": "assets/style-CMHHCLN4.css",
|
||||||
"src": "_style-DaHD49X0.css"
|
"src": "_style-CMHHCLN4.css"
|
||||||
},
|
},
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
||||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
||||||
@ -40,32 +40,32 @@
|
|||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
||||||
},
|
},
|
||||||
"src/home.ts": {
|
"src/home.ts": {
|
||||||
"file": "assets/home-2m6DwiDu.js",
|
"file": "assets/home-f2CGvY1q.js",
|
||||||
"name": "home",
|
"name": "home",
|
||||||
"src": "src/home.ts",
|
"src": "src/home.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_style-CufywNmO.js"
|
"_style-C433w8gz.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/noita.ts": {
|
"src/noita.ts": {
|
||||||
"file": "assets/noita-C7qVNYuP.js",
|
"file": "assets/noita-BcY9X9eS.js",
|
||||||
"name": "noita",
|
"name": "noita",
|
||||||
"src": "src/noita.ts",
|
"src": "src/noita.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_style-CufywNmO.js",
|
"_style-C433w8gz.js",
|
||||||
"_RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js"
|
"_RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/opus-magnum.ts": {
|
"src/opus-magnum.ts": {
|
||||||
"file": "assets/opus_magnum-CgBh_a7R.js",
|
"file": "assets/opus_magnum-CCZTjOvR.js",
|
||||||
"name": "opus_magnum",
|
"name": "opus_magnum",
|
||||||
"src": "src/opus-magnum.ts",
|
"src": "src/opus-magnum.ts",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"imports": [
|
"imports": [
|
||||||
"_style-CufywNmO.js",
|
"_style-C433w8gz.js",
|
||||||
"_RankBadge.vue_vue_type_script_setup_true_lang-C5K0DD3W.js"
|
"_RankBadge.vue_vue_type_script_setup_true_lang-IllWPCyW.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -81,6 +81,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
"current_favorites",
|
"current_favorites",
|
||||||
"last_fetched",
|
"last_fetched",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
"accepting_submissions",
|
||||||
]
|
]
|
||||||
list_filter = ["is_active", "last_fetched", "created_at"]
|
list_filter = ["is_active", "last_fetched", "created_at"]
|
||||||
search_fields = ["title", "steam_id", "author_name", "description"]
|
search_fields = ["title", "steam_id", "author_name", "description"]
|
||||||
@ -115,7 +116,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("Status", {"fields": ("fetch_error",)}),
|
("Status", {"fields": ("fetch_error", "accepting_submissions")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
from ninja.pagination import paginate
|
from ninja.pagination import paginate
|
||||||
|
from ninja.errors import HttpError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@ -10,13 +11,20 @@ from typing import List
|
|||||||
|
|
||||||
from submissions.utils import verify_and_validate_ocr_date_for_submission
|
from submissions.utils import verify_and_validate_ocr_date_for_submission
|
||||||
|
|
||||||
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
|
from .models import (
|
||||||
|
Submission,
|
||||||
|
PuzzleResponse,
|
||||||
|
SubmissionFile,
|
||||||
|
SteamCollectionItem,
|
||||||
|
SteamCollection,
|
||||||
|
)
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
SubmissionIn,
|
SubmissionIn,
|
||||||
SubmissionOut,
|
SubmissionOut,
|
||||||
PuzzleResponseOut,
|
PuzzleResponseOut,
|
||||||
ValidationIn,
|
ValidationIn,
|
||||||
SteamCollectionItemOut,
|
SteamCollectionItemOut,
|
||||||
|
SteamCollectionOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@ -30,6 +38,13 @@ def list_puzzles(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collection", response=SteamCollectionOut)
|
||||||
|
def get_collection(request):
|
||||||
|
"""Get the active collection details"""
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True)
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions", response=List[SubmissionOut])
|
@router.get("/submissions", response=List[SubmissionOut])
|
||||||
@paginate
|
@paginate
|
||||||
def list_submissions(request):
|
def list_submissions(request):
|
||||||
@ -65,7 +80,15 @@ def create_submission(
|
|||||||
if len(files) < len(data.responses):
|
if len(files) < len(data.responses):
|
||||||
return 400, {"detail": "Not enough files for all responses"}
|
return 400, {"detail": "Not enough files for all responses"}
|
||||||
|
|
||||||
print(data, files)
|
# Check if collection is accepting submissions
|
||||||
|
if data.responses:
|
||||||
|
for puzzle in data.responses:
|
||||||
|
if not get_object_or_404(
|
||||||
|
SteamCollectionItem, id=puzzle.puzzle_id
|
||||||
|
).collection.accepting_submissions:
|
||||||
|
raise HttpError(
|
||||||
|
403, "This tournament is no longer accepting submissions"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@ -126,7 +149,6 @@ def create_submission(
|
|||||||
# Process files for this response
|
# Process files for this response
|
||||||
# For simplicity, we'll take one file per response
|
# For simplicity, we'll take one file per response
|
||||||
# In a real implementation, you'd need better file-to-response mapping
|
# In a real implementation, you'd need better file-to-response mapping
|
||||||
print("FI", file_index, files)
|
|
||||||
if file_index < len(files):
|
if file_index < len(files):
|
||||||
uploaded_file = files[file_index]
|
uploaded_file = files[file_index]
|
||||||
|
|
||||||
@ -172,7 +194,6 @@ def create_submission(
|
|||||||
return submission
|
return submission
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-21 10:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("submissions", "0013_steamcollectionitem_points_value"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="steamcollection",
|
||||||
|
name="accepting_submissions",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether the tournament is accepting new submissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -157,6 +157,9 @@ class SteamCollection(models.Model):
|
|||||||
is_active = models.BooleanField(
|
is_active = models.BooleanField(
|
||||||
default=True, help_text="Whether this collection is actively tracked"
|
default=True, help_text="Whether this collection is actively tracked"
|
||||||
)
|
)
|
||||||
|
accepting_submissions = models.BooleanField(
|
||||||
|
default=True, help_text="Whether the tournament is accepting new submissions"
|
||||||
|
)
|
||||||
fetch_error = models.TextField(
|
fetch_error = models.TextField(
|
||||||
blank=True, help_text="Last error encountered when fetching data"
|
blank=True, help_text="Last error encountered when fetching data"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from typing import List, Optional
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
|
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
# Input Schemas
|
# Input Schemas
|
||||||
@ -140,6 +140,26 @@ class PuzzlePointsFactorOut(Schema):
|
|||||||
area: int
|
area: int
|
||||||
|
|
||||||
|
|
||||||
|
class SteamCollectionOut(ModelSchema):
|
||||||
|
"""Schema for Steam collection output"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SteamCollection
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"steam_id",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"author_name",
|
||||||
|
"total_items",
|
||||||
|
"unique_visitors",
|
||||||
|
"current_favorites",
|
||||||
|
"accepting_submissions",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SteamCollectionItemOut(ModelSchema):
|
class SteamCollectionItemOut(ModelSchema):
|
||||||
"""Schema for Steam collection item output"""
|
"""Schema for Steam collection item output"""
|
||||||
|
|
||||||
|
|||||||
@ -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/RankBadge.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/PuzzleResults.vue","./src/components/RankBadge.vue","./src/components/Results.vue","./src/components/SubmissionForm.vue","./src/components/Winners.vue"],"version":"5.9.3"}
|
||||||
Loading…
Reference in New Issue
Block a user