505 lines
18 KiB
Vue
505 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from "vue";
|
|
|
|
interface Objective {
|
|
objectiv_id: string;
|
|
count: number;
|
|
points_per_objectiv?: number;
|
|
total_points?: number;
|
|
}
|
|
|
|
const userInfo = ref({
|
|
username: "Player",
|
|
rank: null as number | null,
|
|
score: 0,
|
|
runsSubmitted: 0,
|
|
isStaff: false,
|
|
});
|
|
|
|
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();
|
|
|
|
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 handleFileUpload = (event: Event) => {
|
|
const input = event.target as HTMLInputElement;
|
|
if (input.files) {
|
|
uploadedFiles.value = Array.from(input.files);
|
|
}
|
|
};
|
|
|
|
const handleDragover = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragover.value = true;
|
|
};
|
|
|
|
const handleDragleave = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragover.value = false;
|
|
};
|
|
|
|
const handleDrop = (event: DragEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
isDragover.value = false;
|
|
|
|
if (event.dataTransfer?.files) {
|
|
uploadedFiles.value = Array.from(event.dataTransfer.files);
|
|
}
|
|
};
|
|
|
|
const submitRun = async () => {
|
|
if (uploadedFiles.value.length === 0) return;
|
|
|
|
isUploading.value = true;
|
|
try {
|
|
for (const file of uploadedFiles.value) {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
const response = await fetch("/api/noita/submit", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
alert(`Error submitting ${file.name}: ${error.detail || "Unknown error"}`);
|
|
return;
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log("Submission successful:", result);
|
|
}
|
|
|
|
uploadedFiles.value = [];
|
|
alert("Run submitted successfully!");
|
|
|
|
// Refresh objectives, score, and rank after successful submission
|
|
await Promise.all([
|
|
fetchUserResults(),
|
|
fetchLeaderboard(),
|
|
]);
|
|
} catch (error) {
|
|
console.error("Error submitting run:", error);
|
|
alert("Error submitting run. Please try again.");
|
|
} finally {
|
|
isUploading.value = false;
|
|
}
|
|
};
|
|
|
|
const goHome = () => {
|
|
window.location.href = "/";
|
|
};
|
|
|
|
const fetchUserResults = async () => {
|
|
try {
|
|
const response = await fetch("/api/noita/results");
|
|
if (!response.ok) throw new Error("Failed to fetch results");
|
|
|
|
const results = await response.json();
|
|
userInfo.value.score = results.total_score;
|
|
userInfo.value.runsSubmitted = results.objectives.length;
|
|
objectives.value = results.objectives;
|
|
} catch (error) {
|
|
console.error("Error fetching results:", error);
|
|
}
|
|
};
|
|
|
|
const fetchLeaderboard = async () => {
|
|
isLoadingLeaderboard.value = true;
|
|
try {
|
|
const response = await fetch("/api/noita/leaderboard");
|
|
if (!response.ok) throw new Error("Failed to fetch leaderboard");
|
|
|
|
const data = await response.json();
|
|
leaderboard.value = data.leaderboard;
|
|
|
|
// Find current user's rank
|
|
const userRank = leaderboard.value.find(
|
|
(entry: any) => entry.username === userInfo.value.username
|
|
);
|
|
|
|
if (userRank) {
|
|
userInfo.value.rank = userRank.rank;
|
|
userInfo.value.score = userRank.total_score;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching leaderboard:", error);
|
|
} finally {
|
|
isLoadingLeaderboard.value = false;
|
|
}
|
|
};
|
|
|
|
const clearCache = async () => {
|
|
try {
|
|
const response = await fetch("/api/cache/clear", {
|
|
method: "POST",
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert("Cache cleared successfully!");
|
|
// Refresh data after clearing cache
|
|
await Promise.all([
|
|
fetchUserResults(),
|
|
fetchLeaderboard(),
|
|
]);
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`Error clearing cache: ${error.detail || "Unknown error"}`);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error clearing cache:", error);
|
|
alert("Error clearing cache. Please try again.");
|
|
}
|
|
};
|
|
|
|
const loadUserData = async () => {
|
|
// Get user info first
|
|
try {
|
|
const response = await fetch("/api/user");
|
|
if (response.ok) {
|
|
const user = await response.json();
|
|
if (user.is_authenticated) {
|
|
userInfo.value.username = user.username;
|
|
userInfo.value.isStaff = user.is_staff || false;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching user info:", error);
|
|
}
|
|
|
|
// Fetch results and leaderboard
|
|
await Promise.all([
|
|
fetchUserResults(),
|
|
fetchLeaderboard(),
|
|
]);
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadUserData();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-base-200">
|
|
<!-- Header -->
|
|
<div class="navbar bg-base-100 shadow-lg">
|
|
<div class="container mx-auto w-full flex items-center gap-4">
|
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
|
<i class="mdi mdi-arrow-left"></i>
|
|
Back
|
|
</button>
|
|
<h1 class="text-xl font-bold">Noita Submitter</h1>
|
|
<div class="flex-1"></div>
|
|
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
|
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Content -->
|
|
<div class="container mx-auto px-4 py-8">
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 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>
|
|
<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>
|
|
|
|
<div class="divider"></div>
|
|
|
|
<div v-if="isLoadingLeaderboard" class="flex justify-center py-8">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<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>
|
|
</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>
|
|
</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>
|
|
</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>
|
|
|
|
<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>
|
|
Clear Cache
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column: Upload -->
|
|
<div class="lg:col-span-2">
|
|
<div class="card bg-base-100 shadow-lg">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-2xl mb-6">
|
|
<i class="mdi mdi-cloud-upload text-purple-500 mr-2"></i>
|
|
Submit Your Run
|
|
</h2>
|
|
|
|
<!-- Upload Area -->
|
|
<div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop" :class="[
|
|
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
|
|
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
|
|
]">
|
|
<input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload"
|
|
accept="text/plain,text/x-log" />
|
|
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
|
|
<i
|
|
:class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
|
|
<div>
|
|
<p class="font-semibold">Click to upload or drag and drop</p>
|
|
<p class="text-sm text-base-content/70">The log file <code>polylan_mod_log.txt</code></p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Uploaded Files List -->
|
|
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
|
<p class="font-semibold mb-3">Selected Files:</p>
|
|
<div class="space-y-2">
|
|
<div v-for="(file, index) in uploadedFiles" :key="index"
|
|
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg">
|
|
<i class="mdi mdi-file text-primary"></i>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="font-medium truncate">{{ file.name }}</p>
|
|
<p class="text-xs text-base-content/70">{{ (file.size / 1024 / 1024).toFixed(2) }} MB</p>
|
|
</div>
|
|
<button @click="uploadedFiles.splice(index, 1)" class="btn btn-ghost btn-xs">
|
|
<i class="mdi mdi-close"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit Button -->
|
|
<div class="flex gap-3">
|
|
<label for="file-upload" class="btn btn-outline flex-1">
|
|
<i class="mdi mdi-folder-open mr-2"></i>
|
|
Choose Files
|
|
</label>
|
|
<button @click="submitRun" :disabled="uploadedFiles.length === 0 || isUploading"
|
|
:class="['btn btn-primary flex-1', { 'loading': isUploading }]">
|
|
<i v-if="!isUploading" class="mdi mdi-send mr-2"></i>
|
|
{{ isUploading ? 'Submitting...' : 'Submit Run' }}
|
|
</button>
|
|
</div>
|
|
|
|
<p class="text-xs text-base-content/70 text-center mt-4">
|
|
Maximum file size: 256 MB per file
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Objectives Table -->
|
|
<div class="card bg-base-100 shadow-lg mt-8">
|
|
<div class="card-body">
|
|
<h2 class="card-title text-2xl mb-6">
|
|
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
|
|
Your Objectives
|
|
</h2>
|
|
|
|
<div v-if="objectives.length === 0" class="text-center py-8">
|
|
<p class="text-base-content/70 mb-2">No objectives completed yet</p>
|
|
<p class="text-sm text-base-content/50">Submit your runs to unlock objectives!</p>
|
|
</div>
|
|
|
|
<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" />
|
|
|
|
<!-- Results Summary -->
|
|
<div class="text-sm text-base-content/70">
|
|
Showing {{ filteredObjectives.length }} of {{ objectives.length }} objectives
|
|
</div>
|
|
|
|
<!-- Objectives Table -->
|
|
<div v-if="filteredObjectives.length > 0" class="overflow-x-auto">
|
|
<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>
|
|
</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">
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- No Results -->
|
|
<div v-if="filteredObjectives.length === 0" class="text-center py-8">
|
|
<p class="text-base-content/70">No objectives match your search</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Leaderboard Modal -->
|
|
<div v-if="isLeaderboardModalOpen" class="modal modal-open">
|
|
<div class="modal-box max-w-4xl">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="font-bold text-lg">
|
|
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
|
|
Global Leaderboard
|
|
</h3>
|
|
<button @click="isLeaderboardModalOpen = false" class="btn btn-sm btn-circle btn-ghost">
|
|
<i class="mdi mdi-close"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Leaderboard Table -->
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra w-full">
|
|
<thead>
|
|
<tr>
|
|
<th>Rank</th>
|
|
<th>Username</th>
|
|
<th class="text-right">Objectives</th>
|
|
<th class="text-right">Score</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="entry in leaderboard" :key="entry.username"
|
|
:class="{ 'bg-primary/20': entry.username === userInfo.username }">
|
|
<td class="font-bold">
|
|
<span v-if="entry.rank === 1" class="badge badge-warning badge-lg">
|
|
🏆 #{{ entry.rank }}
|
|
</span>
|
|
<span v-else-if="entry.rank === 2" class="badge badge-lg">
|
|
🥈 #{{ entry.rank }}
|
|
</span>
|
|
<span v-else-if="entry.rank === 3" class="badge badge-lg">
|
|
🥉 #{{ entry.rank }}
|
|
</span>
|
|
<span v-else>#{{ entry.rank }}</span>
|
|
</td>
|
|
<td class="font-medium">
|
|
{{ entry.username }}
|
|
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-2">
|
|
You
|
|
</span>
|
|
</td>
|
|
<td class="text-right">{{ entry.objectives_count }}</td>
|
|
<td class="text-right font-bold">{{ entry.total_score.toLocaleString() }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="leaderboard.length === 0" class="text-center py-8">
|
|
<p class="text-base-content/70">No entries yet</p>
|
|
</div>
|
|
</div>
|
|
<div class="modal-backdrop" @click="isLeaderboardModalOpen = false"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|