core: contest team (#1183)

This commit is contained in:
Baoshuo authored and GitHub committed 2026-09-16 01:05:44 +08:00
1 parent 238b1263bf
commit 78e898fdfc
22 files changed
+934 -96

No files matched your search

@@ -1,17 +1,100 @@
import $ from 'jquery';
import { ActionDialog, prompt } from 'vj/components/dialog';
import Notification from 'vj/components/notification';
import { AutoloadPage } from 'vj/misc/Page';
import { delay, i18n, request } from 'vj/utils';
const contestPage = new AutoloadPage('contestPage', () => {
$('[data-contest-code]').on('click', (ev) => {
$('[data-contest-team-invite]').on('click', async (ev) => {
const vuid = $(ev.currentTarget).attr('data-vuid');
const res = await prompt(i18n('Invite Member'), {
user: {
type: 'userId',
label: i18n('Username / UID'),
required: true,
autofocus: true,
},
});
if (!res?.user) return;
request.post(location.pathname, { operation: 'invite', vuid, uid: res.user })
.then(() => {
Notification.success(i18n('Invitation sent'));
delay(1000).then(() => window.location.reload());
})
.catch((e) => {
Notification.error(e.message || e);
});
});
$('[data-contest-team-rename]').on('click', function () {
const $card = $(this).closest('[data-contest-team]');
$card.find('[data-contest-team-rename-form]').removeClass('is-hidden')
.find('input[name="name"]').trigger('focus');
$(this).addClass('is-hidden');
});
$('[data-contest-team-rename-cancel]').on('click', function () {
const $card = $(this).closest('[data-contest-team]');
$card.find('[data-contest-team-rename-form]').addClass('is-hidden');
$card.find('[data-contest-team-rename]').removeClass('is-hidden');
});
const $dialogBody = $('.dialog__body--contest-attend > div');
if (!$dialogBody.length) return;
function selectMode(mode: 'personal' | 'team') {
const $button = $dialogBody.find(`[data-contest-attend-mode="${mode}"]`);
if ($button.prop('disabled')) return;
$dialogBody.find('[name="contest_attend_mode"]').val(mode);
$dialogBody.find('[data-contest-attend-mode]').removeClass('primary').attr('aria-pressed', 'false');
$button.addClass('primary').attr('aria-pressed', 'true');
$dialogBody.find('[name="contest_attend_vuid"]').prop('disabled', mode !== 'team');
}
const attendDialog = new ActionDialog({
$body: $dialogBody,
onDispatch(action) {
if (action !== 'ok') return true;
const mode = $dialogBody.find('[name="contest_attend_mode"]').val();
const vuid = $dialogBody.find('[name="contest_attend_vuid"]').val();
if (mode === 'team' && !vuid) {
Notification.error(i18n('Please select a team.'));
return false;
}
const $code = $dialogBody.find('[name="contest_attend_code"]');
if ($code.length && !$code.val()?.toString().trim()) {
Notification.error(i18n('Invitation code is required.'));
return false;
}
return true;
},
});
attendDialog.clear = function () {
selectMode('personal');
this.$dom.find('[name="contest_attend_code"]').val('');
return this;
};
$dialogBody.on('click', '[data-contest-attend-mode]', (ev) => {
selectMode($(ev.currentTarget).attr('data-contest-attend-mode') as 'personal' | 'team');
});
$('[data-contest-attend-form]').on('submit', async (ev) => {
ev.preventDefault();
// eslint-disable-next-line no-alert
const code = prompt(i18n('Invitation code:'));
request.post('', {
operation: 'attend',
code,
}).then(() => {
const $form = $(ev.currentTarget);
const params: Record<string, any> = { operation: 'attend' };
if ($form.is('[data-contest-needs-dialog]')) {
const action = await attendDialog.clear().open();
if (action !== 'ok') return;
const mode = $dialogBody.find('[name="contest_attend_mode"]').val();
if (mode === 'team') params.vuid = $dialogBody.find('[name="contest_attend_vuid"]').val();
const code = $dialogBody.find('[name="contest_attend_code"]').val();
if (code) params.code = code.toString().trim();
}
request.post($form.attr('action') || '', params).then(() => {
Notification.success(i18n('Successfully attended'));
delay(1000).then(() => window.location.reload());
}).catch((e) => {
@@ -19,3 +19,26 @@
.contest-sidebar__status
margin-top: rem(20px)
.contest-attend-dialog__modes
display: grid
grid-template-columns: repeat(2, minmax(0, 1fr))
gap: rem(10px)
margin-bottom: rem(12px)
.button
margin-bottom: 0
text-align: center
white-space: normal
+mobile()
grid-template-columns: 1fr
.contest-attend-dialog__hint
margin-bottom: rem(12px)
.contest-attend-dialog__desc
margin: rem(4px) 0 rem(12px)
.contest-attend-dialog__team-select
margin-top: rem(12px)
@@ -0,0 +1,131 @@
.page--contest_team
.is-hidden
display: none !important
.team-invites-banner
background: lighten($progress-color, 38%)
border: 1px solid lighten($progress-color, 26%)
border-left: 4px solid $progress-color
border-radius: rem(4px)
padding: rem(12px) rem(14px)
margin-bottom: rem($section-margin)
&__head
display: flex
align-items: center
gap: rem(8px)
font-weight: 600
color: darken($progress-color, 28%)
margin-bottom: rem(8px)
.icon
font-size: rem(18px)
&__list
list-style: none
margin: 0
padding: 0
&__row
display: flex
align-items: center
justify-content: space-between
gap: rem(10px)
padding: rem(5px) 0
&:not(:last-child)
border-bottom: 1px solid rgba(#000, 0.06)
&__actions
display: flex
gap: rem(8px)
form
margin: 0
.team-card
background: $content-bg-color
border: 1px solid $border-2-color
border-radius: rem(4px)
padding: rem(14px) rem(16px)
margin-bottom: rem($section-gap-v)
&__header
display: flex
align-items: center
justify-content: space-between
gap: rem(10px)
&__title
h3
font-size: rem($font-size-title)
margin: 0
display: inline
&__meta
display: flex
align-items: center
gap: rem(8px)
&__count
display: inline-flex
align-items: center
gap: rem(4px)
color: $text-3-color
font-size: rem($font-size-small)
&__rename
display: flex
align-items: center
gap: rem(8px)
margin: rem(10px) 0 0
.textbox
flex: 1
&__section
margin-top: rem(12px)
&__label
color: $text-3-color
font-size: rem($font-size-small)
margin: 0 0 rem(6px)
&__list
list-style: none
margin: 0
padding: 0
&__row
display: flex
align-items: center
justify-content: space-between
gap: rem(10px)
padding: rem(5px) 0
&:not(:last-child)
border-bottom: 1px solid rgba(#000, 0.05)
&__actions
display: flex
flex-wrap: wrap
gap: rem(8px)
margin-top: rem(14px)
padding-top: rem(12px)
border-top: 1px solid $table-border-color
form
margin: 0
.team-create-form
.textbox
margin-bottom: rem(8px)
+mobile()
.team-card
&__header
flex-wrap: wrap
&__actions
.button
flex: 1
@@ -174,6 +174,16 @@
value:tdoc.allowPrint|default(false),
row:false
}) }}
{{ form.form_checkbox({
columns:4,
label:'Allow Team',
name:'allowTeam',
placeholder:_('Allow team participation'),
help_text:_('Once enabled, team participation cannot be disabled for this contest.'),
value:tdoc.allowTeam|default(false),
row:false,
disabled:tdoc.allowTeam|default(false)
}) }}
<div style="display: none" class="contest-rule-settings contest-rule--oi contest-rule--strictioi">
{{ form.form_checkbox({
columns:4,
@@ -141,6 +141,18 @@
</div>
</div>
{% endif %}
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<div class="section side">
<div class="section__header">
<h1 class="section__title">
{{ _('My Teams') }}
</h1>
</div>
<div class="section__body">
<p><a href="{{ url('contest_team') }}" class="expanded button"><span class="icon icon-user--multiple"></span> {{ _('Manage Teams') }}</a></p>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,152 @@
{% import "components/user.html" as user with context %}
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
<div class="section__header">
<h1 class="section__title">{{ _('Team Participation') }}</h1>
</div>
<div class="section__body">
{% if invites.length %}
<div class="team-invites-banner">
<div class="team-invites-banner__head">
<span class="icon icon-mail"></span>
<span>{{ _('You have {0} pending team invite(s)').format(invites.length) }}</span>
</div>
<ul class="team-invites-banner__list">
{% for v in invites %}
<li class="team-invites-banner__row">
<span class="team-invites-banner__name">{{ v.displayName }} <small class="supplementary">(ID {{ v._id }})</small></span>
<span class="team-invites-banner__actions">
<form method="post" action="{{ url('contest_team') }}">
<input type="hidden" name="operation" value="accept">
<input type="hidden" name="vuid" value="{{ v._id }}">
<button type="submit" class="primary button small">{{ _('Accept') }}</button>
</form>
<form method="post" action="{{ url('contest_team') }}">
<input type="hidden" name="operation" value="reject">
<input type="hidden" name="vuid" value="{{ v._id }}">
<button type="submit" class="button small">{{ _('Reject') }}</button>
</form>
</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if not mine.length and not invites.length %}
{{ nothing.render('You are not in any team yet. Create one from the panel on the right, or ask a teammate to invite you by your username.') }}
{% endif %}
{% for v in mine %}
<div class="team-card" data-contest-team>
<div class="team-card__header">
<div class="team-card__title">
<h3>{{ v.displayName }}</h3>
<small class="supplementary">(ID {{ v._id }})</small>
</div>
<div class="team-card__meta">
<span class="team-card__count" data-tooltip="{{ _('Members') }}">
<span class="icon icon-user--multiple"></span> {{ v.members.length }}
</span>
<button type="button" class="button small compact" data-contest-team-rename data-tooltip="{{ _('Rename') }}">
<span class="icon icon-edit"></span>
</button>
</div>
</div>
<form class="team-card__rename is-hidden" method="post" action="{{ url('contest_team') }}" data-contest-team-rename-form>
<input type="hidden" name="operation" value="rename">
<input type="hidden" name="vuid" value="{{ v._id }}">
<input type="text" class="textbox" name="name" value="{{ v.displayName }}">
<button type="submit" class="primary button small">{{ _('Save') }}</button>
<button type="button" class="button small" data-contest-team-rename-cancel>{{ _('Cancel') }}</button>
</form>
<div class="team-card__section">
<p class="team-card__label">{{ _('Members') }}</p>
<ul class="team-card__list">
{% for m in v.members %}
<li class="team-card__row">
<span class="team-card__user">{{ user.render_inline(udict[m], badge=false) }}</span>
<form class="team-card__row-action" method="post" action="{{ url('contest_team') }}" onsubmit='return confirm({{ _("Remove this member from the team?")|json|safe }});'>
<input type="hidden" name="operation" value="leave">
<input type="hidden" name="vuid" value="{{ v._id }}">
<input type="hidden" name="uid" value="{{ m }}">
<button type="submit" class="button small compact" data-tooltip="{{ _('Remove') }}">
<span class="icon icon-delete"></span>
</button>
</form>
</li>
{% endfor %}
</ul>
</div>
{% if v.invite and v.invite.length %}
<div class="team-card__section">
<p class="team-card__label">{{ _('Pending Invites') }}</p>
<ul class="team-card__list">
{% for uid in v.invite %}
<li class="team-card__row">
<span class="team-card__user">{{ user.render_inline(udict[uid], badge=false) }}</span>
<form class="team-card__row-action" method="post" action="{{ url('contest_team') }}">
<input type="hidden" name="operation" value="leave">
<input type="hidden" name="vuid" value="{{ v._id }}">
<input type="hidden" name="uid" value="{{ uid }}">
<button type="submit" class="button small compact" data-tooltip="{{ _('Revoke') }}">
<span class="icon icon-close"></span>
</button>
</form>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="team-card__actions">
<button type="button" class="button small" data-contest-team-invite data-vuid="{{ v._id }}">
<span class="icon icon-send"></span> {{ _('Invite Member') }}
</button>
<form method="post" action="{{ url('contest_team') }}" onsubmit='return confirm({{ _("Leave this team? You may lose access to team contests.")|json|safe }});'>
<input type="hidden" name="operation" value="leave">
<input type="hidden" name="vuid" value="{{ v._id }}">
<button type="submit" class="button small">
<span class="icon icon-logout"></span> {{ _('Leave Team') }}
</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="medium-3 columns">
<div class="section side">
<div class="section__header">
<h1 class="section__title">{{ _('Create a Team') }}</h1>
</div>
<div class="section__body">
<form method="post" action="{{ url('contest_team') }}" class="team-create-form">
<input type="hidden" name="operation" value="create">
<input type="text" class="textbox" name="name" placeholder="{{ _('Team name') }}" required>
<button type="submit" class="primary button expanded">{{ _('Create') }}</button>
</form>
</div>
</div>
<div class="section side">
<div class="section__header">
<h1 class="section__title">{{ _('How It Works') }}</h1>
</div>
<div class="section__body typo supplementary">
<p>{{ _('Teams let multiple users participate in a contest together, sharing one set of submissions.') }}</p>
<p>{{ _('Create a team, invite teammates by their username, then choose "Team Participation" when attending an allow-team contest.') }}</p>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -44,12 +44,61 @@
{% if not (tsdoc.attend) and not model.contest.isDone(tdoc) %}
<li class="menu__item">
{% if handler.user.hasPerm(perm.PERM_ATTEND_CONTEST) and handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<form action="{{ url('contest_detail', tid=tdoc.docId) }}" method="POST">
{% set team_vdocs = team_vdocs|default([]) %}
<form action="{{ url('contest_detail', tid=tdoc.docId) }}" method="POST" data-contest-attend-form{% if tdoc.allowTeam or tdoc._code %} data-contest-needs-dialog{% endif %}>
<input type="hidden" name="operation" value="attend">
<button class="menu__link" {% if tdoc._code %}data-contest-code{% endif %} type="submit">
<button class="menu__link" data-contest-attend type="submit">
<span class="icon icon-add"></span> {{ _('Attend Contest') }}
</button>
</form>
<div style="display:none" class="dialog__body--contest-attend">
<div>
<div class="row"><div class="columns">
<h1>{{ _('Attend Contest') }}</h1>
<p class="supplementary contest-attend-dialog__desc">{{ _('Choose how you want to participate in this contest.') }}</p>
</div></div>
<input type="hidden" name="contest_attend_mode" value="personal">
<div class="contest-attend-dialog__modes">
<button type="button" class="primary rounded button" data-contest-attend-mode="personal" aria-pressed="true">
<span class="icon icon-user"></span> {{ _('Personal Participation') }}
</button>
<button type="button" class="rounded button{% if not tdoc.allowTeam %} disabled{% endif %}" data-contest-attend-mode="team" aria-pressed="false"{% if teamDisabled %} disabled{% endif %}>
<span class="icon icon-user--multiple"></span> {{ _('Team Participation') }}
</button>
</div>
{% if not tdoc.allowTeam %}
<p class="supplementary contest-attend-dialog__hint">{{ _('Team participation is not allowed for this contest.') }}</p>
{% elif not team_vdocs.length %}
<p class="supplementary contest-attend-dialog__hint">
{{ _('You are not in any team.') }}
<a class="typo-a" href="{{ url('contest_team') }}">{{ _('Manage Teams') }}</a>
</p>
{% endif %}
{% if team_vdocs.length %}
<div class="row contest-attend-dialog__team-select"><div class="columns">
<label>
{{ _('Team') }}
<select class="select" name="contest_attend_vuid" disabled>
{% for v in team_vdocs %}
<option value="{{ v._id }}">{{ v.displayName }} (ID {{ v._id }})</option>
{% endfor %}
</select>
</label>
</div></div>
<p class="supplementary contest-attend-dialog__hint">
<a class="typo-a" href="{{ url('contest_team') }}">{{ _('Manage Teams') }}</a>
</p>
{% endif %}
{% if tdoc._code %}
<div class="row"><div class="columns">
<label class="material textbox">
{{ _('Invitation code') }}
<input name="contest_attend_code" type="text" data-autofocus>
</label>
</div></div>
{% endif %}
</div>
</div>
{% elif not handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<a class="menu__link" href="javascript:showSignInDialog()">
<span class="icon icon-add"></span> {{ _('Login to Attend Contest') }}
@@ -4,7 +4,22 @@
{%- for tsdoc in tsdocs -%}
<tr data-uid="{{ tsdoc.uid }}">
<td class="col--uid">{{ tsdoc.uid }}</td>
<td class="col--user">{{ user.render_inline(udict[tsdoc.uid], badge=false) }}</td>
<td class="col--user">
{{ user.render_inline(udict[tsdoc.uid], badge=false) }}
{% if tsdoc.displayName or (tsdoc.members and tsdoc.members.length) %}
<br><small class="supplementary">
{% if tsdoc.members and tsdoc.members.length %}
{{ _('Team') }}: {{ tsdoc.displayName }} —
{% for uid in tsdoc.members %}
{%- if not loop.first -%}, {% endif -%}
{{ user.render_inline(udict[uid], avatar=false, badge=false) }}
{%- endfor -%}
{% else %}
{{ tsdoc.displayName }}
{% endif %}
</small>
{% endif %}
</td>
<td class="col--start-time">{{ contest.render_time(tsdoc.startAt or tdoc.beginAt) }}</td>
<td class="col--end-time">{{ contest.render_time(tsdoc.endAt or tdoc.endAt) }}</td>
<td class="col--rank">{{ _('UnRank') if tsdoc.unrank else _('Rank') }}</td>
@@ -49,12 +49,25 @@
{%- if column.type == 'rank' -%}
<span class="{{ 'rank--unrank' if column.value == '0' else 'rank--normal' }}">{{ '*' if column.value == '0' else column.value|string|nl2br|safe }}</span>
{%- elif column.type == 'user' -%}
{%- set canView = canViewAll or handler.user._id == column.raw -%}
{%- set canView = canViewAll or handler.user._id == column.raw or (handler.team and handler.team == column.raw) -%}
<button class="star user--{{ column.raw }}" data-uid="{{ column.raw }}">
<span class="starred--hide"><span class="icon icon-star--outline" data-tooltip="{{ _('Star') }}"></span></span>
<span class="starred--show"><span class="icon icon-star" data-tooltip="{{ _('Unstar') }}"></span></span>
</button>
{{ user.render_inline(udict[column.raw], badge=false) }}
{%- if udict[column.raw].teamMembers -%}
<span class="user-profile-link">
<span class="icon icon-user--multiple"></span>
<span class="user-profile-name">{{ udict[column.raw].displayName }}</span>
</span>
<br><small class="supplementary">
{%- for memberUid in udict[column.raw].teamMembers -%}
{%- if loop.index0 -%}, {% endif -%}
{{ user.render_inline(udict[memberUid], avatar=false, badge=false) }}
{%- endfor -%}
</small>
{%- else -%}
{{ user.render_inline(udict[column.raw], badge=false) }}
{%- endif -%}
{%- elif column.type == 'record' and column.raw -%}
{{ renderRecord(column, canView) }}
{%- elif column.type == 'records' -%}
@@ -76,4 +89,4 @@
</tbody>
</table>
</div>
</div>
</div>