Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/contest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"algohub": patch:feat
---

Support create new contest.
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/components/UniversalToolBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ const createMenuItems = ref([
command: () => {
toast.add({ severity: 'info', summary: 'Coming soon...', detail: 'This feature is coming soon...' })
}
},
{
separator: true
},
{
label: 'New Contest',
icon: 'pi pi-calendar',
command: () => {
router.push("/contest/create");
}
}
]);
const toggleCreateMenu = (event: any) => {
Expand Down
10 changes: 10 additions & 0 deletions src/scripts/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
CreateProblem,
Submission,
Contest,
CreateContest,
} from "./types";

export interface Response<D> {
Expand Down Expand Up @@ -208,3 +209,12 @@ export const listAllContests = async (auth: Credentials) => {
return handleAxiosError(AxiosError.from(error));
}
};

export const createContest = async (data: CreateContest) => {
try {
const response = await axios.post("/contest/create", data);
return response.data as Response<Id>;
} catch (error) {
return handleAxiosError(AxiosError.from(error));
}
};
15 changes: 15 additions & 0 deletions src/scripts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,18 @@ export interface Contest {
created_at: string;
updated_at: string;
}

export interface ContestData {
name: string;
mode: Mode;
visibility: Visibility;
description: string;
start_time: string;
end_time: string;
owner: RecordId;
}

export interface CreateContest {
auth: Credentials;
data: ContestData;
}
221 changes: 49 additions & 172 deletions src/views/contest/create.vue
Original file line number Diff line number Diff line change
@@ -1,205 +1,82 @@
<script setup lang="ts">
import { type FileUploadSelectEvent, usePrimeVue, useToast } from 'primevue';
import { computed, reactive, ref } from 'vue';
import { useToast } from 'primevue';
import * as api from "@/scripts/api";
import { useRouter } from 'vue-router';
import { useAccountStore } from '@/scripts/store';
import type { RecordId } from '@/scripts/types';
import { ref } from 'vue';
import { Mode, Visibility } from '@/scripts/types';

const path = [{ label: 'New problem' }];

const router = useRouter();
const toast = useToast();
const $primevue = usePrimeVue();

const accountStore = useAccountStore();
if (!accountStore.isLoggedIn) {
toast.add({ severity: 'error', summary: 'Error', detail: 'Please login first', life: 3000 });
router.push('/login');
}

const title = ref('');
const name = ref('');
const description = ref('');
const input = ref('');
const output = ref('');
const samples = reactive<{ input: string, output: string }[]>([{ input: '', output: '' }]);
const hint = ref('');
const mode = ref<'ICPC' | 'OI'>('ICPC');
const isPrivate = ref(false);
const testCases = reactive<{ input: string, output: string }[]>([]);

const timeLimit = ref<number>(1000);
const memoryLimit = ref<number>(128);

interface ProblemForm<T, N> {
title: T;
description: T;
input?: T;
output?: T;
samples: { input: T, output: T }[];
hint?: T;
time_limit: N;
memory_limit: N;
test_cases: { input: T, output: T }[];
owner: RecordId,
categories: string[];
tags: string[];
mode: 'ICPC' | 'OI';
private: boolean;
}

const validate = (form: ProblemForm<string, number>): boolean => {
if (!form.title || form.title.trim() === '') {
toast.add({ severity: 'error', summary: 'Error', detail: 'Title should not be a blank', life: 3000 });
return false;
} else if (form.title.length > 32) {
toast.add({ severity: 'error', summary: 'Error', detail: 'Title is too long (max 32 characters)', life: 3000 });
return false;
}
if (!form.description || form.description.trim() === '') {
toast.add({ severity: 'error', summary: 'Error', detail: 'Description should not be a blank', life: 3000 });
return false;
} else if (form.description.length > 2000) {
toast.add({ severity: 'error', summary: 'Error', detail: 'Description is too long (max 2000 characters)', life: 3000 });
return false;
}
return true;
}
const start_time = ref<Date>();
const end_time = ref<Date>();

const inProgress = ref(false);
const onCreateProblem = async () => {
const problem: ProblemForm<string, number> = {
title: title.value,
description: description.value,
input: input.value || undefined,
output: output.value || undefined,
samples: samples.map(sample => ({ input: sample.input, output: sample.output })),
hint: hint.value || undefined,
time_limit: timeLimit.value,
memory_limit: memoryLimit.value,
test_cases: testCases.map(tc => ({ input: tc.input, output: tc.output })),
// @ts-ignore
owner: "account:" + accountStore.account.id,
categories: [],
tags: [],
mode: mode.value,
private: isPrivate.value
}
const valid = validate(problem);
if (!valid) return;

if (inProgress.value) return;
const onCreateContest = async () => {
inProgress.value = true;
const res = await api.createProblem({
id: accountStore.account.id!,
token: accountStore.account.token!,
...problem,
});
const res = await api.createContest({
auth: accountStore.auth!,
data: {
name: name.value,
description: description.value,
start_time: start_time.value!.toISOString().replace('Z', ''),
end_time: end_time.value!.toISOString().replace('Z', ''),
mode: Mode.ICPC,
visibility: Visibility.Public,
owner: accountStore.recordId
}
})
if (!res.success) {
inProgress.value = false;
return toast.add({ severity: 'error', summary: 'Error', detail: res.message, life: 3000 });
}
toast.add({ severity: 'error', summary: 'Error', detail: res.message, life: 3000 });
};
inProgress.value = false;
router.push(`/problem/${res.data!.id}`);
}

const totalSize = ref(0);
const totalUploadedSize = ref(0);
const totalSizePercent = computed(() => (totalUploadedSize.value / totalSize.value) * 100);
const normalizedFiles = ref<{ input?: File, output?: File }[]>([]);

const onRemoveTemplatingFile = (
type: 'input' | 'output',
context: {
removeFileCallback: (index: number) => void,
plainFiles: File[],
normalizedIndex: number,
}
) => {
const testCase = normalizedFiles.value[context.normalizedIndex];
const fileRemoved = type === 'input' ? testCase.input! : testCase.output!;
testCase[type] = undefined;
if (!testCase.input && !testCase.output) {
normalizedFiles.value.splice(context.normalizedIndex, 1);
}
context.removeFileCallback(context.plainFiles.indexOf(fileRemoved));
totalSize.value -= parseInt(formatSize(fileRemoved.size));
return true;
};

const onClearTemplatingUpload = (clear: () => void) => {
clear();
totalSize.value = 0;
totalUploadedSize.value = 0;
};

const onSelectedFiles = (event: FileUploadSelectEvent) => {
normalizedFiles.value = normalizeFiles(event.files);

event.files.forEach((file: File) => {
totalSize.value += parseInt(formatSize(file.size));
});
};

const normalizeFiles = (files: File[]) => {
const normalizedFiles: { input?: File, output?: File }[] = [];

files.forEach((file) => {
const dotIndex = file.name.lastIndexOf('.');
const fileName = file.name.substring(0, dotIndex);
const extension = file.name.substring(dotIndex + 1);

if (extension === 'in') {
const index = normalizedFiles.findIndex(f => f.output?.name === fileName + '.out');
if (index !== -1) {
normalizedFiles[index].input = file;
} else {
normalizedFiles.push({ input: file });
}
} else if (extension === 'out') {
const index = normalizedFiles.findIndex(f => f.input?.name === fileName + '.in');
if (index !== -1) {
normalizedFiles[index].output = file;
} else {
normalizedFiles.push({ output: file });
}
}
});

return normalizedFiles;
router.push('/contest/' + res.data!.id);
}

const formatSize = (bytes: number) => {
const k = 1024;
const dm = 3;
const sizes = $primevue.config.locale?.fileSizeTypes || [0];

if (bytes === 0) {
return `0 ${sizes[0]}`;
}

const i = Math.floor(Math.log(bytes) / Math.log(k));
const formattedSize = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));

return `${formattedSize} ${sizes[i]}`;
};
</script>

<template>
<div class="flex-1 flex flex-col">
<UniversalToolBar :path></UniversalToolBar>
<div class="max-w-full md:max-w-[768px] mx-auto">
<Panel class="mt-10">

<div class="max-w-full w-[768px] md:max-w-[768px] mx-auto">
<Panel class="mt-10 w-full h-full">
<div class="flex flex-col gap-8 w-full">
<div class="mt-10 text-center">
<span class="text-gray-500 mb-4">Create a new contest</span>
<h1 class="text-3xl font-bold">Create contest</h1>
</div>
<div class="flex flex-row gap-4">
<div class="flex flex-col">
<label for="owner">Owner *</label>
<Select name="owner" disabled></Select>
</div>
<span class="flex flex-col justify-end">
<span class="text-bold mb-2">/</span>
</span>
<div class="flex flex-col">
<label for="name">Name *</label>
<InputText v-model="name" name="name"></InputText>
</div>
</div>
<MarkdownEditor v-model="description" placeholder="Description"></MarkdownEditor>
<div class="flex flex-row gap-4">
<DatePicker v-model="start_time" placeholder="Start date" showTime></DatePicker>
<DatePicker v-model="end_time" placeholder="End date" showTime></DatePicker>
</div>
<Button @click="onCreateContest" :loading="inProgress" label="Save Changes"></Button>
</div>
</Panel>
</div>
<UniversalFooter></UniversalFooter>
</div>
</template>

<style scoped>
:deep(svg.md-editor-icon) {
width: revert-layer;
height: revert-layer;
}
</style>
Loading
Loading