tournament outputs

This commit is contained in:
2026-05-22 06:18:11 +02:00
parent bf21a5eae6
commit 92dddca964
21 changed files with 925 additions and 69 deletions
+67 -33
View File
@@ -4,10 +4,12 @@ import PuzzleCard from "@/components/PuzzleCard.vue";
import SubmissionForm from "@/components/SubmissionForm.vue";
import AdminPanel from "@/components/AdminPanel.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 { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions";
import type { PuzzleResponse, UserInfo } from "@/types";
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
import { useCountdown } from "@vueuse/core";
import { storeToRefs } from "pinia";
@@ -26,6 +28,7 @@ const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
// Local state
const userInfo = ref<UserInfo | null>(null);
const collection = ref<SteamCollection | null>(null);
const isLoading = ref(true);
const error = ref<string>("");
@@ -34,6 +37,10 @@ const isSuperuser = computed(() => {
return userInfo.value?.is_superuser || false;
});
const isTournamentClosed = computed(() => {
return !!(collection.value && !collection.value.accepting_submissions);
});
// Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {};
@@ -66,6 +73,16 @@ async function initialize() {
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
console.log("Loading puzzles...");
await puzzlesStore.loadPuzzles();
@@ -173,45 +190,62 @@ const goHome = () => {
<!-- Main Content -->
<div v-else class="space-y-8">
<!-- Collection Info -->
<div class="mb-8">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
<p class="text-base-content/70">
{{ props.collectionDescription }}
</p>
<div class="flex flex-wrap gap-4 mt-4">
<button @click="openSubmissionModal" class="btn btn-primary">
<i class="mdi mdi-plus mr-2"></i>
Submit Solution
</button>
<!-- Winners Section (only when tournament is closed) -->
<div v-if="isTournamentClosed" class="space-y-8">
<Winners />
<PuzzleResults />
</div>
<template v-else>
<!-- Collection Info -->
<div class="mb-8">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
<p class="text-base-content/70">
{{ props.collectionDescription }}
</p>
<div class="flex flex-wrap gap-4 mt-4">
<button @click="openSubmissionModal" class="btn btn-primary" :disabled="isTournamentClosed">
<i class="mdi mdi-plus mr-2"></i>
Submit Solution
</button>
</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>
<Results />
<Results />
<!-- Admin Panel (only for superusers) -->
<div v-if="isSuperuser">
<AdminPanel />
</div>
<!-- Admin Panel (only for superusers) -->
<div v-if="isSuperuser">
<AdminPanel />
</div>
<!-- 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] || []" />
</div>
<!-- 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] || []" />
</div>
<!-- Empty State -->
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
<div class="text-6xl mb-4">🧩</div>
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
<p class="text-base-content/70">
Check back later for new puzzle collections!
</p>
</div>
<!-- Empty State -->
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
<div class="text-6xl mb-4">🧩</div>
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
<p class="text-base-content/70">
Check back later for new puzzle collections!
</p>
</div>
</template>
</div>
</div>
@@ -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>
@@ -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>
+21 -1
View File
@@ -1,9 +1,13 @@
import type {
SteamCollection,
SteamCollectionItem,
Submission,
PuzzleResponse,
SubmissionFile,
UserInfo
UserInfo,
TournamentWinners,
TournamentSubmissions,
TournamentPuzzleResults
} from '../types'
// API Configuration
@@ -101,6 +105,22 @@ export class ApiService {
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
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
return this.request<PaginatedResponse<Submission>>(
+66
View File
@@ -7,6 +7,7 @@ export interface SteamCollection {
total_items: number
unique_visitors: number
current_favorites: number
accepting_submissions: boolean
created_at: string
updated_at: string
}
@@ -107,3 +108,68 @@ export interface UserInfo {
is_superuser: boolean
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[]
}