feat(submissions): base submissions frontend + api
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
interface Props {
|
||||
gamePath?: string;
|
||||
gameName?: string;
|
||||
gameImage?: string;
|
||||
gameSteamId?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
gamePath: "",
|
||||
gameName: "Game",
|
||||
gameImage: "",
|
||||
gameSteamId: "",
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
|
||||
const uploadedFiles = ref<File[]>([]);
|
||||
const isDragover = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const notes = ref("");
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const filePreviewUrl = ref<string>("");
|
||||
const isModalOpen = ref(false);
|
||||
|
||||
const openFilePreview = async (file: File) => {
|
||||
selectedFile.value = file;
|
||||
|
||||
const fileType = file.type;
|
||||
if (fileType.startsWith("image/")) {
|
||||
filePreviewUrl.value = URL.createObjectURL(file);
|
||||
} else if (fileType === "application/pdf") {
|
||||
filePreviewUrl.value = URL.createObjectURL(file);
|
||||
} else if (fileType.startsWith("text/")) {
|
||||
const text = await file.text();
|
||||
filePreviewUrl.value = text;
|
||||
} else {
|
||||
filePreviewUrl.value = "";
|
||||
}
|
||||
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
if (filePreviewUrl.value.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(filePreviewUrl.value);
|
||||
}
|
||||
selectedFile.value = null;
|
||||
filePreviewUrl.value = "";
|
||||
};
|
||||
|
||||
const getGameHeaderImage = () => {
|
||||
if (props.gameImage) {
|
||||
return props.gameImage;
|
||||
}
|
||||
if (props.gameSteamId) {
|
||||
return `https://cdn.akamai.steamstatic.com/steam/apps/${props.gameSteamId}/header.jpg`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getFileIcon = (file: File | null) => {
|
||||
if (!file) return "mdi-file";
|
||||
const type = file.type;
|
||||
if (type.startsWith("image/")) return "mdi-image";
|
||||
if (type === "application/pdf") return "mdi-file-pdf";
|
||||
if (type.startsWith("text/")) return "mdi-file-document";
|
||||
if (type.includes("video")) return "mdi-video";
|
||||
return "mdi-file";
|
||||
};
|
||||
|
||||
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 removeFile = (index: number) => {
|
||||
uploadedFiles.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const submitSubmission = async () => {
|
||||
if (uploadedFiles.value.length === 0) return;
|
||||
|
||||
isUploading.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add files
|
||||
uploadedFiles.value.forEach((file) => {
|
||||
formData.append("files", file);
|
||||
});
|
||||
|
||||
// Add form fields
|
||||
formData.append("notes", notes.value);
|
||||
|
||||
// Build URL with query parameters
|
||||
const url = new URL("/api/submissions/create", window.location.origin);
|
||||
url.searchParams.append("game_path", props.gamePath);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMsg = Array.isArray(errorData.detail)
|
||||
? errorData.detail.map((e: any) => e.msg).join(", ")
|
||||
: errorData.detail || "Failed to submit";
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
uploadedFiles.value = [];
|
||||
notes.value = "";
|
||||
alert("Submission successful!");
|
||||
} catch (error) {
|
||||
console.error("Error submitting:", error);
|
||||
alert(`Error submitting: ${error instanceof Error ? error.message : "Please try again."}`);
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
userStore.loadUserData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-200">
|
||||
<!-- Header -->
|
||||
<div class="navbar bg-base-100 shadow-lg">
|
||||
<div class="container min-w-3/4 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">{{ gameName }} Submissions</h1>
|
||||
<div class="flex-1"></div>
|
||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
||||
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Right Column: Upload -->
|
||||
<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">
|
||||
<i class="mdi mdi-cloud-upload text-primary mr-2"></i>
|
||||
Submit Results
|
||||
</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" />
|
||||
<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">Any file type supported</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 cursor-pointer hover:bg-base-300 transition-colors group"
|
||||
@click="openFilePreview(file)">
|
||||
<i :class="['mdi text-primary', getFileIcon(file)]"></i>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate group-hover:text-primary">{{ file.name }}</p>
|
||||
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p>
|
||||
</div>
|
||||
<button @click.stop="removeFile(index)" class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="mb-6">
|
||||
<div class="form-control">
|
||||
<div class="flex-1">
|
||||
<label class="flex label">
|
||||
<span class="label-text font-medium">Notes (Optional)</span>
|
||||
<span class="label-text-alt">0/500</span>
|
||||
</label>
|
||||
|
||||
<textarea class="flex textarea textarea-bordered h-24 w-full resize-none"
|
||||
placeholder="Add any notes special for this upload" maxlength="500"></textarea>
|
||||
</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="submitSubmission" :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' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-base-content/70 text-center mt-4">
|
||||
Maximum file size: 256 MB per file
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left Column: Info -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-8">
|
||||
<!-- Game Card -->
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<figure v-if="getGameHeaderImage()" class="relative h-48 bg-base-300 overflow-hidden">
|
||||
<img :src="getGameHeaderImage()!" :alt="gameName" class="w-full h-full object-cover" />
|
||||
<div class="absolute inset-0 bg-black/30"></div>
|
||||
</figure>
|
||||
<figure v-else class="relative h-48 bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center">
|
||||
<i class="mdi mdi-gamepad-variant text-6xl text-white opacity-30"></i>
|
||||
</figure>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">{{ gameName }}</h2>
|
||||
<p class="text-base-content/70">Submit your best results for this game</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guidelines Card -->
|
||||
<div class="card bg-base-100 shadow-lg sticky top-8">
|
||||
<div class="bg-gradient-to-br from-blue-600 to-blue-400 p-8 text-white rounded-t-2xl">
|
||||
<h2 class="text-3xl font-bold">
|
||||
<i class="mdi mdi-information-outline text-3xl"></i>
|
||||
Submission Guidelines
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body p-8">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="font-bold text-lg mb-2">
|
||||
<i class="mdi mdi-file-upload mr-2 text-primary"></i>
|
||||
File Upload
|
||||
</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Upload your results as screenshots, logs, or any other file format. You can upload multiple
|
||||
files at once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-bold text-lg mb-2">
|
||||
<i class="mdi mdi-note-text mr-2 text-primary"></i>
|
||||
Add Notes
|
||||
</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Include any additional context about your submission. This helps reviewers understand your
|
||||
results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-bold text-lg mb-2">
|
||||
<i class="mdi mdi-check-circle mr-2 text-primary"></i>
|
||||
Verification
|
||||
</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Your submission will be reviewed and verified. You'll receive confirmation once it's been
|
||||
processed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="bg-base-200 p-4 rounded-lg">
|
||||
<p class="text-base-content/70 text-sm">
|
||||
<i class="mdi mdi-lightbulb-on mr-2 text-warning"></i>
|
||||
Pro tip: Include clear screenshots or documentation with your submission for faster verification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Modal -->
|
||||
<div :class="['modal', { 'modal-open': isModalOpen }]">
|
||||
<div class="modal-box w-11/12 max-w-4xl">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
<i :class="['mdi mr-2', getFileIcon(selectedFile)]"></i>
|
||||
{{ selectedFile?.name }}
|
||||
</h3>
|
||||
|
||||
<!-- Image Preview -->
|
||||
<div v-if="selectedFile && selectedFile.type.startsWith('image/')" class="mb-4">
|
||||
<img :src="filePreviewUrl" :alt="selectedFile.name" class="max-w-full max-h-[60vh] mx-auto rounded-lg" />
|
||||
</div>
|
||||
|
||||
<!-- PDF Preview -->
|
||||
<div v-else-if="selectedFile && selectedFile.type === 'application/pdf'" class="mb-4">
|
||||
<iframe :src="filePreviewUrl" class="w-full h-[60vh] rounded-lg border border-base-300"></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Text Preview -->
|
||||
<div v-else-if="selectedFile && selectedFile.type.startsWith('text/')" class="mb-4">
|
||||
<div class="bg-base-200 p-4 rounded-lg max-h-[60vh] overflow-y-auto">
|
||||
<pre class="text-sm whitespace-pre-wrap break-words font-mono">{{ filePreviewUrl }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unsupported Preview -->
|
||||
<div v-else class="mb-4">
|
||||
<div class="bg-base-200 p-8 rounded-lg text-center">
|
||||
<i class="mdi mdi-file-question text-6xl text-base-content/30 mb-4"></i>
|
||||
<p class="text-base-content/70">
|
||||
Preview not available for {{ selectedFile?.type || 'this file type' }}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/50">
|
||||
File size: {{ selectedFile ? formatFileSize(selectedFile.size) : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button @click="closeModal" class="btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" @click="closeModal"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user