manual upload works in happy path + delete game

This commit is contained in:
Trit0 2025-03-14 15:13:03 -04:00
parent 4a26259891
commit 174e7e57eb
19 changed files with 1424 additions and 521 deletions

1149
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"@date-io/date-fns": "^3.2.1",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.6",
@ -18,8 +19,10 @@
"lucide-vue-next": "^0.479.0",
"mqtt": "^5.3.3",
"pinia": "^2.1.6",
"vite-plugin-vuetify": "^2.1.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4"
"vue-router": "^4.2.4",
"vuetify": "^3.7.16"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.10.5",
@ -40,8 +43,8 @@
"sass": "^1.68.0",
"tailwindcss": "^3.3.3",
"typescript": "^5.7.3",
"vite": "^4.4.9",
"vitest": "^0.34.4",
"vite": "^5.4.14",
"vitest": "^3.0.8",
"vue-tsc": "^2.2.2"
}
}

View File

@ -0,0 +1,74 @@
<template>
<div>
<v-menu
v-model="menu"
:close-on-content-click="false"
:nudge-right="40"
transition="scale-transition"
offset-y
min-width="290px"
>
<template v-slot:activator="{ props }">
<v-text-field
v-bind="props"
:value="dateFormatted"
variant="outlined"
append-inner-icon="mdi-calendar"
@change="onChange"
@input="updateDate"
></v-text-field>
</template>
<v-date-picker
:model-value="getDate"
@update:modelValue="updateDate"
></v-date-picker>
</v-menu>
</div>
</template>
<script>
export default {
props: {
/**
* Date on ISO format to be edited.
* @model
*/
value: {
type: String,
default() {
return ""
},
},
},
data() {
return {
menu: false,
};
},
computed: {
dateFormatted() {
return this.input ? new Date(this.input) : "";
},
getDate() {
const date = this.input ? new Date(this.input) : new Date()
return [date]
}
},
methods: {
onChange(val) {
console.log(val)
},
updateDate(val) {
this.menu = false;
console.log(val)
this.input = val
},
},
};
</script>
<style scoped>
.v-text-field input {
background-color: transparent !important;
}
</style>

View File

@ -0,0 +1,50 @@
export class UploadGameDto {
title: string;
description: string;
medias: Blob[];
filesBlob: Blob;
files?: string;
genres: string[];
developers: string[];
publicRepositoryLink: string;
players: string;
release: string;
modification: string;
version: string;
leaderboard: boolean;
thumbnail?: string;
image?: string;
constructor(formData: FormData, mediaFileList: FileList) {
this.title = formData.get("title") as string;
this.description = formData.get("description") as string;
this.publicRepositoryLink = formData.get("repo") as string;
this.players = formData.get("players") as string;
this.release = new Date(formData.get("release") as string).toISOString().split("T")[0];
this.modification = new Date().toISOString().split("T")[0];
this.version = formData.get("version") as string;
this.leaderboard = !!formData.get("leaderboard");
this.medias = []
for (let i = 0; i < mediaFileList.length; i++) {
this.medias[i] = mediaFileList[i];
}
this.filesBlob = formData.get("game") as Blob;
this.genres = (formData.get("genres") as string).split(",").map(s => s.trim());
this.developers = (formData.get("devs") as string).split(",").map(s => s.trim());
}
async setMediasAndFiles() {
if (this.medias.length > 0) {
this.thumbnail = await this.medias[0].text();
}
if (this.medias.length > 1) {
this.image = await this.medias[1].text();
}
this.files = await this.filesBlob.text();
}
}

View File

@ -0,0 +1,39 @@
export class ValidateMetadataDto {
id: string;
game: string;
description: string;
files: string;
genres: string[];
developers: string[];
publicRepositoryLink: string;
players: string;
release: string;
modification: string;
version: string;
leaderboard: boolean;
unityLibraryVersion?: string;
thumbnail: number[];
image: number[];
constructor(formData: FormData, thumbnail: Uint8Array | null, image: Uint8Array | null, filePath: string, id: string) {
this.id = id
this.game = formData.get("title") as string;
this.description = formData.get("description") as string;
this.publicRepositoryLink = formData.get("repo") as string;
this.players = formData.get("players") as string;
this.release = new Date(formData.get("release") as string).toISOString().split("T")[0];
this.modification = new Date().toISOString().split("T")[0];
this.version = formData.get("version") as string;
this.leaderboard = !!formData.get("leaderboard");
this.thumbnail = !!thumbnail ? Array.from(thumbnail) : [];
this.image = !!image ? Array.from(image): [];
this.files = filePath;
this.genres = (formData.get("genres") as string).split(",").map(s => s.trim().toLowerCase());
this.developers = (formData.get("devs") as string).split(",").map(s => s.trim());
}
}

View File

@ -3,12 +3,26 @@ import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
// Vuetify
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import DateFnsAdapter from '@date-io/date-fns'
import App from './App.vue'
import router from './router'
const vuetify = createVuetify({
components,
directives,
theme: false,
})
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vuetify)
app.mount('#app')

View File

@ -33,6 +33,11 @@ const router = createRouter({
name: 'upload',
component: () => import('../views/games/UploadView.vue'),
},
{
path: '/manual-upload',
name: 'manual-upload',
component: () => import('../views/games/ManualUploadView.vue'),
},
{
path: '/events',
name: 'events',

View File

@ -3,9 +3,11 @@ export class BaseService {
protected apiUrl: string | undefined = import.meta.env.VITE_CONJUREOS_HOST
protected baseHeaders: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'API-Version': '1'
}
protected jsonHeaders: Record<string, string> = {
'Content-Type': 'application/json',
}
public constructor() {
}
@ -24,6 +26,18 @@ export class BaseService {
return await fetch(this.apiUrl + path, {
method: 'POST',
body: JSON.stringify(body) as any,
headers: {
...headers,
...this.jsonHeaders,
...this.baseHeaders
}
});
}
protected async postForm(path: string, body: FormData, headers?: HeadersInit): Promise<Response> {
return await fetch(this.apiUrl + path, {
method: 'POST',
body: body,
headers: {
...headers,
...this.baseHeaders
@ -35,6 +49,17 @@ export class BaseService {
return await fetch(this.apiUrl + path, {
method: 'PUT',
body: JSON.stringify(body),
headers: {
...headers,
...this.jsonHeaders,
...this.baseHeaders
}
});
}
protected async delete(path: string, headers?: HeadersInit): Promise<Response> {
return await fetch(this.apiUrl + path, {
method: 'DELETE',
headers: {
...headers,
...this.baseHeaders

View File

@ -1,5 +1,8 @@
import { BaseService } from './base-service'
import { Game } from '../interfaces/game'
import { Game } from '@/interfaces/game'
import { ValidateMetadataDto } from '@/dtos/validate-metadata.dto'
import { useAuthStore } from '@/stores/auth'
import { AuthDto } from '@/dtos/auth.dto'
export class GameService extends BaseService {
@ -11,43 +14,50 @@ export class GameService extends BaseService {
return this.get<Game>(`games/${gameId}`)
}
public async upload(game: Game) : Promise<void> {
return this.post<any>(`games`, null).then();
public async upload(game: FormData) : Promise<Response> {
const authStr = useAuthStore();
return this.postForm(`games`, game, {
Authorization: `Bearer ${authStr.getAuth()?.token}`,
});
}
public async update(game: Game) : Promise<void> {
return this.put<any>(`games`, null).then();
await this.put<any>(`games`, null)
}
public async activate(gameId: string): Promise<void> {
return this.post<any>(`games/${gameId}/activate`, null).then();
await this.post<any>(`games/${gameId}/activate`, null)
}
public async deactivate(gameId: string): Promise<void> {
return this.post<any>(`games/${gameId}/deactivate`, null).then();
await this.post<any>(`games/${gameId}/deactivate`, null)
}
public async download(gameId: string): Promise<void> {
return this.get<any>(`games/${gameId}/download`).then();
await this.get<any>(`games/${gameId}/download`)
}
public async downloadAll(): Promise<void> {
return this.get<any>(`games/download`).then();
await this.get<any>(`games/download`)
}
public async patch(): Promise<void> {
return this.put<any>(`games/patch`, null).then();
await this.put<any>(`games/patch`, null)
}
public async minor(): Promise<void> {
return this.put<any>(`games/minor`, null).then();
await this.put<any>(`games/minor`, null)
}
public async major(): Promise<void> {
return this.put<any>(`games/major`, null).then();
await this.put<any>(`games/major`, null)
}
public async metadata(): Promise<void> {
return this.post<any>(`games/metadata`, null).then();
public async metadata(dto: ValidateMetadataDto): Promise<Response> {
return this.post(`games/metadata`, dto)
}
public async deleteGame(gameId: string): Promise<void> {
await this.delete(`games/${gameId}`)
}
}

8
src/utils/blob.util.ts Normal file
View File

@ -0,0 +1,8 @@
export class BlobUtil {
static getFileName(file: File | Blob): string {
if (file instanceof File) {
return file.name;
}
return (file as File).name
}
}

46
src/utils/conj.util.ts Normal file
View File

@ -0,0 +1,46 @@
export class ConjUtil {
static scoreExecutable(gameTitle: string, fileName: string): number {
const normalizedTitle = gameTitle.toLowerCase().replace(/[^a-z0-9]/g, ''); // Remove spaces & special chars
const normalizedFile = fileName.toLowerCase().replace(/[^a-z0-9]/g, '');
let score = 0;
// ✅ High score if exact match (ignoring case & symbols)
if (normalizedFile === normalizedTitle) score += 100;
// ✅ Reward filenames that contain the game title
if (normalizedFile.includes(normalizedTitle)) score += 50;
// ✅ Favor files that end in the game title
if (normalizedFile.endsWith(normalizedTitle)) score += 25;
// ✅ Favor filenames that are short (avoid engine-related long names)
score += Math.max(0, 20 - normalizedFile.length / 5);
// ❌ Penalize common engine-related executables
const engineFiles = [
"unitycrashhandler", "unrealengine", "unrealeditor",
"launcher", "updater", "configtool", "settings"
];
if (engineFiles.some(engine => normalizedFile.includes(engine))) score -= 50;
return score;
}
static findBestExecutable(gameTitle: string, exeList: string[]): string | null {
if (exeList.length === 0) return null;
let bestMatch = exeList[0];
let highestScore = -Infinity;
for (const exe of exeList) {
const score = this.scoreExecutable(gameTitle, exe);
if (score > highestScore) {
highestScore = score;
bestMatch = exe;
}
}
return bestMatch;
}
}

17
src/utils/file-saver.ts Normal file
View File

@ -0,0 +1,17 @@
export class FileSaver {
static saveFile(file: File) {
const a = document.createElement('a');
document.body.appendChild(a);
a.style.display = 'none';
const url = window.URL.createObjectURL(file);
a.href = url;
a.download = file.name;
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
static saveBlob(blob: Blob, filename: string) {
return this.saveFile(new File([blob], filename));
}
}

9
src/utils/uuid.util.ts Normal file
View File

@ -0,0 +1,9 @@
export class GuidUtil {
static generateUUIDv4(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
const random = Math.random() * 16 | 0;
const value = char === 'x' ? random : (random & 0x3 | 0x8);
return value.toString(16);
});
}
}

147
src/utils/zip.util.ts Normal file
View File

@ -0,0 +1,147 @@
export class ZipUtil {
zipParts: Uint8Array<ArrayBuffer>[] = []
centralDirectory: Uint8Array<ArrayBuffer>[] = [];
offset: number = 0;
private crc32(buffer: Uint8Array): number {
let table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c;
}
let crc = 0xFFFFFFFF;
for (let i = 0; i < buffer.length; i++) {
crc = (crc >>> 8) ^ table[(crc ^ buffer[i]) & 0xFF];
}
return ~crc >>> 0;
}
private createFileHeader(fileName: string, fileData: ArrayBuffer): Uint8Array<ArrayBuffer> {
const encodedFileName = new TextEncoder().encode(fileName);
const crc = this.crc32(new Uint8Array(fileData));
const fileSize = fileData.byteLength;
const header = new Uint8Array(30 + encodedFileName.length);
const view = new DataView(header.buffer);
view.setUint32(0, 0x04034b50, true); // Local file header signature
view.setUint16(4, 20, true); // Version
view.setUint16(6, 0, true); // General purpose flag
view.setUint16(8, 0, true); // Compression method (0 = no compression)
view.setUint16(10, 0, true); // File modification time
view.setUint16(12, 0, true); // File modification date
view.setUint32(14, crc, true); // CRC-32
view.setUint32(18, fileSize, true); // Compressed size
view.setUint32(22, fileSize, true); // Uncompressed size
view.setUint16(26, encodedFileName.length, true); // Filename length
view.setUint16(28, 0, true); // Extra field length
header.set(encodedFileName, 30);
return header;
}
private createCentralDirectoryEntry(fileName: string, fileSize: number, crc: number, fileOffset: number): Uint8Array<ArrayBuffer> {
const encodedFileName = new TextEncoder().encode(fileName);
const entry = new Uint8Array(46 + encodedFileName.length);
const view = new DataView(entry.buffer);
view.setUint32(0, 0x02014b50, true); // Central file header signature
view.setUint16(4, 20, true); // Version
view.setUint16(6, 20, true); // Version needed to extract
view.setUint16(8, 0, true); // General purpose flag
view.setUint16(10, 0, true); // Compression method (0 = no compression)
view.setUint16(12, 0, true); // File modification time
view.setUint16(14, 0, true); // File modification date
view.setUint32(16, crc, true); // CRC-32
view.setUint32(20, fileSize, true); // Compressed size
view.setUint32(24, fileSize, true); // Uncompressed size
view.setUint16(28, encodedFileName.length, true); // Filename length
view.setUint16(30, 0, true); // Extra field length
view.setUint16(32, 0, true); // File comment length
view.setUint16(34, 0, true); // Disk number start
view.setUint16(36, 0, true); // Internal file attributes
view.setUint32(38, 0, true); // External file attributes
view.setUint32(42, fileOffset, true); // Relative offset of local header
entry.set(encodedFileName, 46);
return entry;
}
createEndOfCentralDirectory() {
const eocd = new Uint8Array(22);
const view = new DataView(eocd.buffer);
view.setUint32(0, 0x06054b50, true); // End of central directory signature
view.setUint16(4, 0, true); // Number of this disk
view.setUint16(6, 0, true); // Disk where central directory starts
view.setUint16(8, this.centralDirectory.length, true); // Number of central directory records on this disk
view.setUint16(10, this.centralDirectory.length, true); // Total number of central directory records
view.setUint32(12, this.centralDirectory.reduce((sum, entry) => sum + entry.length, 0), true); // Size of central directory
view.setUint32(16, this.offset, true); // Offset of central directory
view.setUint16(20, 0, true); // Comment length
return eocd;
}
async addFile(filePath: string, fileBlob: Blob): Promise<void> {
const fileData = await fileBlob.arrayBuffer();
const fileHeader = this.createFileHeader(filePath, fileData);
this.zipParts.push(fileHeader, new Uint8Array(fileData));
const fileOffset = this.offset;
this.offset += fileHeader.length + fileData.byteLength;
this.centralDirectory.push(this.createCentralDirectoryEntry(filePath, fileData.byteLength, this.crc32(new Uint8Array(fileData)), fileOffset));
}
getZipBlob(): Blob {
this.zipParts.push(...this.centralDirectory, this.createEndOfCentralDirectory());
return new Blob(this.zipParts, { type: "application/zip" });
}
static async createZipBlob(files: { filePath: string; fileBlob: Blob }[]): Promise<Blob> {
const util = new ZipUtil();
for (const { filePath, fileBlob } of files) {
await util.addFile(filePath, fileBlob);
}
return util.getZipBlob();
}
static async getZipFileTree(zipBlob: Blob): Promise<string[]> {
const fileTree: string[] = [];
// Read ZIP Blob into an ArrayBuffer
const arrayBuffer = await zipBlob.arrayBuffer();
const dataView = new DataView(arrayBuffer);
let offset = arrayBuffer.byteLength - 22; // Start near the end of the ZIP file
// Find End of Central Directory Record
while (offset > 0) {
if (dataView.getUint32(offset, true) === 0x06054b50) break; // Signature for End of Central Directory
offset--;
}
if (offset <= 0) throw new Error("Invalid ZIP file: No End of Central Directory found");
// Read the Central Directory Offset
const centralDirOffset = dataView.getUint32(offset + 16, true);
offset = centralDirOffset;
// Parse Central Directory Headers to extract filenames
while (offset < arrayBuffer.byteLength) {
if (dataView.getUint32(offset, true) !== 0x02014b50) break; // Central Directory File Header
const fileNameLength = dataView.getUint16(offset + 28, true);
const extraFieldLength = dataView.getUint16(offset + 30, true);
const commentLength = dataView.getUint16(offset + 32, true);
// Extract the filename
const fileNameBytes = new Uint8Array(arrayBuffer, offset + 46, fileNameLength);
const fileName = new TextDecoder().decode(fileNameBytes);
fileTree.push(fileName);
// Move to the next entry
offset += 46 + fileNameLength + extraFieldLength + commentLength;
}
return fileTree;
}
}

View File

@ -24,12 +24,13 @@ const logout = () => {
<RouterLink to="/">
<img alt="Conjure logo" class="logo max-w-lg" src="@/assets/logo_conjure_dark.png"/>
</RouterLink>
<nav>
<RouterLink to="/games">Games</RouterLink>
<RouterLink to="/upload">Upload</RouterLink>
</nav>
<!-- <nav>-->
<!-- <RouterLink to="/games">Games</RouterLink>-->
<!-- <RouterLink to="/upload">Upload</RouterLink>-->
<!-- </nav>-->
</div>
<button id="logout" class="p-2 rounded-full border hover:border-gray-300" @click="logout()" v-if="auth">
<button id="logout" class="border-transparent font-bold text-foreground py-2 px-4 border hover:border-primary rounded mb-5"
@click="logout()" v-if="auth">
<LogOutIcon class="m-2" />
</button>
</header>

View File

@ -5,6 +5,7 @@ import Loader from '@/components/Loader.vue';
import {useRoute} from 'vue-router';
import {useAuthStore} from '@/stores/auth';
import {storeToRefs} from 'pinia';
import router from '@/router';
const errorStore = useErrorStore();
const authStore = useAuthStore();
@ -15,6 +16,7 @@ const route = useRoute();
const gameId = ref(route.params.gameId);
const game = ref(undefined);
const isActivating = ref(false);
const confirmingDeletion = ref(false);
onMounted(() => {
fetch(apiHost + 'games/' + gameId.value, {
@ -31,6 +33,39 @@ onMounted(() => {
});
});
function tryDeleteGame() {
console.log("Try")
confirmingDeletion.value = true;
new Promise((_) => setTimeout(_, 1000)).then(() => {
confirmingDeletion.value = false;
});
}
function deleteGame() {
console.log("Delete")
fetch(`${apiHost}games/${gameId.value}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${auth.value.token}`,
'API-Version': 1,
},
})
.then((response) => {
if (response.ok) {
alert("Deleted")
router.back()
}
else {
alert("Error deleting game")
}
return true;
})
.catch((error) => {
errorStore.unshift(error);
});
}
function toggleActivation(state) {
isActivating.value = true;
fetch(`${apiHost}games/${gameId.value}/${state ? 'activate' : 'deactivate'}`, {
@ -74,6 +109,13 @@ function toggleActivation(state) {
>
{{ game.active ? 'Deactivate' : 'Activate' }}
</button>
<button
class="font-bold py-2 px-4 m-2 rounded bg-red-500 text-white hover:bg-red-700"
@click="confirmingDeletion ? deleteGame() : tryDeleteGame()"
>
{{ confirmingDeletion ? "Sure?" : "Delete" }}
</button>
</template>
</div>
</article>

View File

@ -7,6 +7,7 @@ import Loader from '@/components/Loader.vue'
import { RouterLink } from 'vue-router'
import { GameService } from '@/services/game.service'
import { DownloadIcon } from 'lucide-vue-next'
import router from '@/router';
const errorStore = useErrorStore()
const apiHost = import.meta.env.VITE_CONJUREOS_HOST
@ -41,6 +42,10 @@ function downloadAll() {
a.click()
document.body.removeChild(a)
}
function newGame() {
router.push("/upload")
}
</script>
<template>
@ -49,12 +54,20 @@ function downloadAll() {
<template v-else>
<header class="flex flex-row justify-between items-center">
<h1 class="text-foreground">My Games</h1>
<button
class="bg-transparent font-bold text-foreground py-2 px-4 border border-primary rounded hover:bg-primary"
@click="downloadAll()"
>
Download All
</button>
<div class="flex gap-3">
<button
class="bg-transparent font-bold text-foreground py-2 px-4 border border-primary rounded hover:bg-primary"
@click="downloadAll()"
>
Download All
</button>
<button
class="bg-transparent font-bold text-foreground py-2 px-4 border border-primary rounded hover:bg-primary"
@click="newGame()"
>
New Game
</button>
</div>
</header>
<ul class="flex flex-col gap-4">
<li v-for="item in gamelist" :key="item.id" class="game-item border border-gray-300 rounded-lg p-4 flex items-center gap-4 hover:border-2">

View File

@ -0,0 +1,221 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import DateInput from '@/components/DateInput.vue'
import { UploadGameDto } from '@/dtos/upload-game.dto'
import { GameService } from '@/services/game.service'
import { ValidateMetadataDto } from '@/dtos/validate-metadata.dto'
import { ZipUtil } from '@/utils/zip.util'
import { BlobUtil } from '@/utils/blob.util'
import { FileSaver } from '@/utils/file-saver'
import { GuidUtil } from '@/utils/uuid.util'
import { ConjUtil } from '@/utils/conj.util'
let playerValue = ref("");
let versionValue = ref("")
let uploadForm = ref()
// onMounted(() => {
// testData();
// });
function validatePlayersInput(): void {
let value = playerValue.value.replace(/[^1-2]/g, ""); // Allow only digits and hyphen
value = value.slice(0, 3); // Ensure max length of 3
if (value.length === 2 && !value.includes("-")) {
value = value.charAt(0) + "-" + value.charAt(1); // Auto-insert hyphen
}
if (value.length === 3) {
const min = +value.charAt(0)
let max = +value.charAt(2)
if (max < min)
max = min
value = `${min}-${max}`
}
playerValue.value = value
}
function validateVersionInput(): void {
versionValue.value = versionValue.value.replace(/[^0-9.]/g, ""); // Allow only digits and hyphen
}
async function submitForm(): Promise<void> {
console.log("Submitting...")
const formData = new FormData(uploadForm.value)
const files = (document.getElementById("medias") as HTMLInputElement).files
let thumbnail = null;
let tbnFile = null;
let image = null
let imageFile = null;
if (!!files && files.length > 0) {
tbnFile = files[0];
thumbnail = new Uint8Array(await tbnFile.arrayBuffer());
}
if (!!files && files.length > 1) {
imageFile = files[1];
image = new Uint8Array(await imageFile.arrayBuffer());
}
const game = formData.get("game") as File;
const gameSplit = game.name.split(".");
const ext = gameSplit[gameSplit.length - 1];
let gamePath = "";
if (ext === "zip") {
let fileTree = await ZipUtil.getZipFileTree(game)
fileTree = fileTree.filter(file => file.includes(".exe"))
const title = formData.get("title") as string;
const bestMatch = ConjUtil.findBestExecutable(title, fileTree);
if (!bestMatch) throw new Error("No executable found in zip file");
const folder = BlobUtil.getFileName(game).split(".")[0]
gamePath = `${folder}\\${bestMatch}`;
}
else if (ext === "exe") {
gamePath = game.name;
}
else {
throw new Error("Unsupported file type");
}
const id = GuidUtil.generateUUIDv4();
const dto = new ValidateMetadataDto(formData, thumbnail, image, gamePath, id);
const service = new GameService();
const response = await service.metadata(dto);
let metadataText = await response.text();
console.log(metadataText)
const metadataObj: any = {};
let metadataLines = metadataText.split("\n");
metadataLines = metadataLines.slice(0, metadataLines.length - 1);
for (const line of metadataLines) {
const lineSplit = line.split(":");
metadataObj[lineSplit[0].trim()] = lineSplit[1].trim();
}
if (!!tbnFile)
metadataObj["thumbnailPath"] = `medias\\${BlobUtil.getFileName(tbnFile)}`
if (!!imageFile)
metadataObj["imagePath"] = `medias\\${BlobUtil.getFileName(imageFile)}`
console.log(metadataObj)
metadataText = Object.keys(metadataObj).map(key => [key, metadataObj[key]].join(": ")).join("\n");
console.log(metadataText);
const metadataBlob = new Blob([metadataText], { type: "text/plain" });
const zipUtil = new ZipUtil();
if (!!files) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileName = BlobUtil.getFileName(file);
await zipUtil.addFile(`medias/${fileName}`, file);
}
}
await zipUtil.addFile("metadata.txt", metadataBlob);
await zipUtil.addFile(BlobUtil.getFileName(game), game);
const zipBlob = zipUtil.getZipBlob();
const zipFile = new File([zipBlob], `${id}.conj`)
FileSaver.saveFile(zipFile);
// TODO remove form data
const conjUploadForm = new FormData();
conjUploadForm.append("file", zipFile);
const uploadResponse = await service.upload(conjUploadForm)
//alert(await uploadResponse.text())
}
function testData(): void {
(document.getElementById("title") as HTMLInputElement).value = "Game Test";
(document.getElementById("description") as HTMLInputElement).value = "Ceci est un test";
(document.getElementById("genres") as HTMLInputElement).value = "Action, Adventure";
(document.getElementById("devs") as HTMLInputElement).value = "Jean,Yussef";
(document.getElementById("repo") as HTMLInputElement).value = "https://repo.com";
(document.getElementById("players") as HTMLInputElement).value = "1-2";
(document.getElementById("release") as HTMLInputElement).value = "01/01/2021";
(document.getElementById("version") as HTMLInputElement).value = "1.1.1";
}
</script>
<template>
<article>
<h1 class="text-foreground">Manual Upload</h1>
<div class="text-foreground w-full my-2 underline">
<RouterLink to="upload" class="hover:color-blue-300">Or upload a .conj</RouterLink>
</div>
<form ref="uploadForm" enctype="multipart/form-data" class="flex flex-col gap-2" @submit.prevent="submitForm">
<label for="title">Title</label>
<input name="title" id="title" class="border border-primary rounded-lg px-3 py-2 bg-transparent focus:outline-none focus:border-accent" />
<label for="description">Description</label>
<textarea name="description" id="description" class="border border-primary rounded-lg px-3 py-2 bg-transparent focus:outline-none focus:border-accent" />
<label for="medias">Media</label>
<input type="file" name="medias" id="medias" accept="video/quicktime,image/png,image/jpeg,video/mp4" multiple />
<label for="game">Game</label>
<input type="file" name="game" id="game" accept="application/zip,application/exe"/>
<label for="genres">Genres</label>
<input name="genres" id="genres" class="border border-primary rounded-lg px-3 py-2 bg-transparent focus:outline-none focus:border-accent"
placeholder="Action, Adventure"
/>
<label for="devs">Devs</label>
<input name="devs" id="devs" class="border border-primary rounded-lg px-3 py-2 bg-transparent focus:outline-none focus:border-accent"
placeholder="Jean, Yussef"
/>
<label for="repo">Public Repository</label>
<input name="repo" id="repo" class="border border-primary rounded-lg px-3 py-2 bg-transparent focus:outline-none focus:border-accent"
placeholder="https://github.com/..."
/>
<label for="players">Player Count</label>
<input
name="players"
id="players"
class="border border-primary rounded-lg px-3 py-2 bg-transparent focus:outline-none focus:border-accent"
v-model="playerValue"
placeholder="0-0"
pattern="\d-\d"
@input="validatePlayersInput"
maxlength="3"
/>
<!-- <date-input></date-input>-->
<label for="release">Release Date</label>
<input
name="release"
id="release"
class="border border-primary rounded-lg px-3 py-2 bg-transparent focus:outline-none focus:border-accent"
placeholder="00/00/0000"
/>
<label for="version">Version</label>
<input
name="version"
id="version"
class="border border-primary rounded-lg px-3 py-2 bg-transparent focus:outline-none focus:border-accent"
v-model="versionValue"
placeholder="0.0.0"
pattern="\d.\d.\d"
@input="validateVersionInput"
/>
<label for="leaderboard">Leaderboard</label>
<input
name="leaderboard"
id="leaderboard"
type="checkbox"
/>
<button
class="bg-transparent font-bold text-foreground py-2 px-4 my-2 border border-primary rounded hover:bg-primary"
type="submit"
>
Upload
</button>
</form>
</article>
</template>
<style scoped>
</style>

View File

@ -22,7 +22,7 @@ export default {
method: 'POST',
body: formData,
headers: {
Authorization: authStr.getAuth(),
Authorization: `Bearer ${authStr.getAuth().token}`,
'API-Version': 1
}
})
@ -46,7 +46,7 @@ export default {
<template>
<article>
<h1 class="text-foreground">Upload</h1>
<form ref="uploadForm" enctype="multipart/form-data" @submit.prevent="submitForm">
<form ref="uploadForm" enctype="multipart/form-data" class="flex flex-col gap-2" @submit.prevent="submitForm">
<!-- <div class="name-input-wrapper">-->
<!-- <label for="name" class="block text-sm font-medium text-gray-700">Name:</label>-->
<!-- <input type="text" name="name" id="name" required v-model="textInput"-->
@ -59,6 +59,7 @@ export default {
name="file"
id="file"
accept=".conj"
class="cursor-pointer"
@change="filesChanges"
/>
<label>
@ -67,8 +68,13 @@ export default {
<em class="file-name" v-if="!!selectedFiles?.length">{{ selectedFiles[0].name }}</em>
</label>
</div>
<div class="text-foreground w-full my-2 underline">
<RouterLink to="manual-upload" class="hover:color-blue-300">Or manually enter your metadata</RouterLink>
</div>
<button
class="bg-transparent text-gray-700 font-semibold hover:text-white py-2 px-4 border border-gray-500 hover:border-transparent rounded"
class="bg-transparent font-bold text-foreground py-2 px-4 border border-primary rounded hover:bg-primary"
type="submit"
>
Upload
@ -78,11 +84,9 @@ export default {
</template>
<style scoped lang="scss">
form {
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 2rem;
}
.file-upload-wrapper {