Skip to content

Commit

Permalink
feat: practice book
Browse files Browse the repository at this point in the history
  • Loading branch information
Baiqiang committed Oct 7, 2024
1 parent 2ab9222 commit 726ae91
Show file tree
Hide file tree
Showing 23 changed files with 767 additions and 53 deletions.
4 changes: 4 additions & 0 deletions components/app/header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const navs = computed(() => [
title: 'endless.title',
path: '/endless',
},
{
title: 'practice.title',
path: '/practice',
},
{
title: 'chain.title',
path: '/chain',
Expand Down
259 changes: 259 additions & 0 deletions components/competition/form.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
scramble: Scramble
competition: Competition
submissions: Submission[]
type?: string
allowUnlimited?: boolean
}>(), {
type: 'weekly',
allowUnlimited: true,
})
const emit = defineEmits<{
submitted: []
}>()
const { t } = useI18n()
const submissionsMap = computed(() => {
const ret: Record<number, Submission> = {}
for (const submission of props.submissions) ret[submission.mode] = submission
return ret
})
const form = reactive({
mode: CompetitionMode.REGULAR,
solution: '',
comment: '',
})
// if there is an unlimited submission, use unlimited mode
if (submissionsMap.value[CompetitionMode.UNLIMITED])
form.mode = CompetitionMode.UNLIMITED
const localForm = useLocalStorage<Record<number, Record<number, { solution: string, comment: string }>>>(`form.${props.type}.${props.competition.alias}`, {})
onMounted(() => {
const localValue = submissionsMap.value[form.mode] || localForm.value[props.scramble.number]?.[form.mode]
if (localValue) {
form.solution = localValue.solution
form.comment = localValue.comment
}
})
watch(form, (state) => {
localForm.value = {
...localForm.value,
[props.scramble.number]: {
...localForm.value[props.scramble.number],
[form.mode]: {
solution: state.solution,
comment: state.comment,
},
},
}
})
watch(() => form.mode, (mode) => {
const localValue = submissionsMap.value[mode] || localForm.value[props.scramble.number]?.[mode]
if (localValue) {
form.solution = localValue.solution
form.comment = localValue.comment
}
else {
form.solution = ''
form.comment = ''
}
if (mode === CompetitionMode.REGULAR && submissionsMap.value[CompetitionMode.UNLIMITED] && !submissionsMap.value[CompetitionMode.REGULAR])
form.solution = t('weekly.regular.unlimitedSubmitted')
})
const { moves, isSolved } = useComputedState(props, form)
const solutionState = computed<boolean | null>(() => {
if (form.solution.length === 0)
return null
if (!isSolved.value)
return false
return moves.value !== DNF
})
const solutionDisabled = computed<boolean>(() => {
if (form.mode === CompetitionMode.UNLIMITED)
return false
if (props.submissions.length === 0)
return false
return true
})
const unlimitedWorse = computed<boolean>(() => {
return form.mode === CompetitionMode.UNLIMITED && props.submissions.some(s => s.moves < moves.value * 100)
})
const formState = computed<boolean>(() => {
if (form.mode === CompetitionMode.REGULAR && !submissionsMap.value[CompetitionMode.REGULAR] && submissionsMap.value[CompetitionMode.UNLIMITED])
return false
if (unlimitedWorse.value)
return false
return solutionState.value !== null
})
const { confirm, cancel, reveal, isRevealed } = useConfirmDialog()
const loading = ref(false)
const confirmMessage = ref(t('weekly.confirmDNF'))
async function submit() {
loading.value = true
try {
if (form.mode === CompetitionMode.REGULAR && submissionsMap.value[CompetitionMode.REGULAR]) {
const { data, refresh } = await useApiPost<Submission>(`/${props.type}/${props.competition.alias}/${submissionsMap.value[CompetitionMode.REGULAR].id}`, {
body: {
comment: form.comment,
},
immediate: false,
})
await refresh()
if (data.value)
emit('submitted')
}
else {
if (moves.value === DNF) {
confirmMessage.value = t('weekly.confirmDNF')
const { isCanceled } = await reveal()
if (isCanceled)
return
}
const { data, refresh } = await useApiPost<Submission>(`/${props.type}/${props.competition.alias}`, {
body: {
scrambleId: props.scramble.id,
mode: form.mode,
solution: form.solution,
comment: form.comment,
},
immediate: false,
})
await refresh()
if (data.value)
emit('submitted')
}
}
catch (e: any) {
if (e.response && e.response.data && e.response.data.message)
alert(e.response.data.message)
else alert(e.message)
}
finally {
loading.value = false
}
}
async function turnToUnlimited() {
try {
confirmMessage.value = t('weekly.turnToUnlimited.confirm')
const { isCanceled } = await reveal()
if (isCanceled)
return
const { data, refresh } = await useApiPost<Submission>(`/weekly/${props.competition.alias}/${submissionsMap.value[CompetitionMode.REGULAR].id}/unlimited`, {
immediate: false,
})
await refresh()
if (data.value) {
emit('submitted')
form.solution = t('weekly.regular.unlimitedSubmitted')
}
}
catch (e: any) {
if (e.response && e.response.data && e.response.data.message)
alert(e.response.data.message)
else alert(e.message)
}
}
function reset() {
if (!submissionsMap.value[form.mode])
form.solution = ''
form.comment = ''
}
</script>

<template>
<div class="mt-6">
<form class="relative" @submit="submit" @reset="reset">
<FormSignInRequired />
<FormInput
v-if="allowUnlimited"
v-model="form.mode"
type="radio"
:label="$t('weekly.mode.label')"
:state="null"
:attrs="{ required: true }"
:options="[
{ label: $t('weekly.regular.label'), description: $t('weekly.regular.description'), value: CompetitionMode.REGULAR },
{ label: $t('weekly.unlimited.label'), description: $t('weekly.unlimited.description'), value: CompetitionMode.UNLIMITED },
]"
/>
<FormInput
v-model="form.solution"
type="textarea"
:rows="4"
:label="$t('weekly.solution.label') + (submissionsMap[form.mode] ? $t('weekly.submitted') : '')"
:state="solutionState"
:attrs="{ required: true, disabled: solutionDisabled }"
>
<template #description>
<div v-if="unlimitedWorse" class="text-red-500">
{{ $t('weekly.unlimited.invalid') }}
</div>
<div v-if="moves !== DNF" class="text-green-500 text-bold">
{{ $t('common.moves', { moves }) }}
</div>
<div v-else class="text-red-500 text-bold">
DNF
</div>
<I18nT keypath="if.scramble.description" tag="p" scope="global">
<template #notation>
<Notation />
</template>
</I18nT>
</template>
</FormInput>
<FormInput
v-model="form.comment"
type="textarea"
:rows="4"
:label="$t('weekly.comment.label')"
:state="null"
class="mt-4"
>
<template #description>
<p class="py-1" v-html="$t('weekly.comment.description')" />
</template>
</FormInput>
<div class="mt-4">
<button
class="px-2 py-1 text-white bg-blue-500 focus:outline-none"
:class="{ 'bg-opacity-50 cursor-not-allowed': !formState }"
:disabled="!formState"
@click.prevent="submit"
>
<Spinner v-if="loading" class="w-4 h-4 text-white border-[3px]" />
<template v-else>
{{ $t('form.submit') }}
</template>
</button>
<button class="px-2 py-1 text-white bg-gray-500 focus:outline-none ml-2" @click.prevent="reset">
{{ $t('form.reset') }}
</button>
<button
v-if="allowUnlimited && form.mode === CompetitionMode.REGULAR && submissionsMap[CompetitionMode.REGULAR] && !submissionsMap[CompetitionMode.UNLIMITED]"
class="px-2 py-1 text-white bg-orange-500 focus:outline-none ml-2"
@click.prevent="turnToUnlimited"
>
{{ $t('weekly.turnToUnlimited.label') }}
</button>
</div>
</form>
</div>
<Teleport to="body">
<Modal v-if="isRevealed" :cancel="cancel">
<div class="mb-5 font-bold">
{{ confirmMessage }}
</div>
<div class="flex gap-2 justify-end">
<button class="bg-rose-500 hover:bg-opacity-90 text-white cursor-pointer px-2 py-1" @click="confirm">
{{ $t('form.confirm') }}
</button>
<button class="bg-gray-300 hover:bg-opacity-80 cursor-pointer px-2 py-1" @click="cancel">
{{ $t('form.cancel') }}
</button>
</div>
</Modal>
</Teleport>
</template>
32 changes: 32 additions & 0 deletions components/practice/info.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup lang="ts">
const props = defineProps<{
competition: Practice
}>()
const index = computed(() => props.competition.alias.split('-').reverse()[0])
const link = computed(() => {
const { user } = props.competition
return `/practice/${user.wcaId || user.id}/${index.value}`
})
</script>

<template>
<div class="pt-2">
<NuxtLink :to="link" class="col-span-12 md:col-span-6 lg:col-span-4 flex items-center gap-2 text-blue-500">
<div>
{{ $t('practice.number', { number: index }) }}
</div>
<UserAvatarName :user="competition.user" :link="false" />
</NuxtLink>
<div class="flex text-sm gap-2 mt-2">
<div>
{{ $t(`common.${CompetitionFormat[competition.format].toLowerCase()}`) }}
</div>
<div>
{{ $t('endless.progress.competitors', { competitors: competition.attendees }) }}
</div>
<div>
{{ $dayjs(competition.startTime).format('YYYY-MM-DD HH:mm:ss') }}
</div>
</div>
</div>
</template>
29 changes: 29 additions & 0 deletions components/spoiler.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
show: boolean
spoiled: string
}>(), {
show: false,
})
const showSpoiler = ref(props.show)
</script>

<template>
<div class="relative">
<div
v-if="!showSpoiler"
class="absolute inset-0 z-50 bg-indigo-500 text-white flex flex-col items-center justify-center cursor-pointer"
@click="showSpoiler = true"
>
<I18nT keypath="common.spoiler" tag="div" scope="global" class="flex items-center">
<template #for>
<span class="text-lg font-bold">
{{ spoiled }}
</span>
</template>
</I18nT>
</div>
<slot />
</div>
</template>
2 changes: 1 addition & 1 deletion components/tabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ onMounted(() => {

<template>
<div>
<div class="overflow-x-auto">
<div v-if="tabs.length > 1" class="overflow-x-auto">
<div class="flex text-xs md:text-sm whitespace-nowrap">
<a
v-for="{ name, hash }, index in tabs"
Expand Down
4 changes: 1 addition & 3 deletions components/user/avatar-info.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ defineProps<{
<NuxtLink :to="`/profile/${user.wcaId || user.id}`" class="flex gap-1 items-center">
<UserAvatar :user="user" :size="8" :link="false" />
<div class="w-full overflow-hidden">
<div class="whitespace-nowrap text-blue-500 text-ellipsis overflow-hidden">
{{ localeName(user.name, $i18n.locale) }}
</div>
<UserName :user="user" class="text-blue-500 text-ellipsis overflow-hidden" />
<div v-if="$slots.info" class="text-gray-400 text-xs">
<slot name="info" />
</div>
Expand Down
14 changes: 7 additions & 7 deletions components/user/avatar-name.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
<script setup lang="ts">
import { NuxtLink } from '#components'
withDefaults(defineProps<{
user: User
link?: boolean
size?: number
link?: boolean
}>(), {
link: true,
size: 6,
link: true,
})
</script>

<template>
<NuxtLink :to="`/profile/${user.wcaId || user.id}`" class="flex items-center">
<component :is="link ? NuxtLink : 'div'" :to="`/profile/${user.wcaId || user.id}`" class="flex items-center">
<UserAvatar :user="user" class="mr-1" :size="size" :link="false" />
<div class="whitespace-nowrap text-blue-500">
{{ localeName(user.name, $i18n.locale) }}
</div>
<UserName :user="user" :class="{ 'text-blue-500': link }" />
<slot />
</NuxtLink>
</component>
</template>
11 changes: 11 additions & 0 deletions components/user/name.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
user: User
}>()
</script>

<template>
<div class="whitespace-nowrap">
{{ localeName(user.name, $i18n.locale) }}
</div>
</template>
Loading

0 comments on commit 726ae91

Please sign in to comment.