--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import List from './List';
+import { getAssignedRounds, missingGroupAssignment } from '../../helpers/Tournament';
+import { useUser } from '../../hooks/user';
+
+const GroupInterface = ({ selfAssign, tournament }) => {
+ const { t } = useTranslation();
+ const { user } = useUser();
+
+ const assignedRounds = React.useMemo(() => getAssignedRounds(tournament, user), [tournament, user]);
+
+ if (!user) {
+ return <div><p>{t('groups.loginRequired')}</p></div>
+ }
+
+ if (missingGroupAssignment(tournament, user)) {
+ return <div>
+ <p>{t('groups.missingAssignments')}</p>
+ <Button onClick={selfAssign}>
+ {t('groups.selfAssignButton')}
+ </Button>
+ </div>
+ }
+
+ return <List rounds={assignedRounds} tournament={tournament} />;
+};
+
+GroupInterface.propTypes = {
+ selfAssign: PropTypes.func,
+ tournament: PropTypes.shape({
+ }),
+};
+
+export default GroupInterface;
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import Badge from '../results/Badge';
+import ReportButton from '../results/ReportButton';
+import SeedButton from '../rounds/SeedButton';
+import SeedCode from '../rounds/SeedCode';
+import { mayReportResult } from '../../helpers/permissions';
+import { formatNumberAlways } from '../../helpers/Round';
+import { findResult } from '../../helpers/User';
+import { useUser } from '../../hooks/user';
+
+const getStatusIcon = (round, result, t) => {
+ if (!result) {
+ return <Button className="group-status" title={t('groups.pendingResult')} variant="outline-secondary">
+ <Icon.PENDING title="" />
+ </Button>;
+ }
+ if (!result.vod) {
+ return <Button className="group-status" title={t('groups.missingVod')} variant="outline-warning">
+ <Icon.WARNING title="" />
+ </Button>;
+ }
+ return <Button className="group-status" title={t('groups.complete')} variant="success">
+ <Icon.FINISHED />
+ </Button>;
+}
+
+const Item = ({
+ round,
+ tournament,
+}) => {
+ const { t } = useTranslation();
+ const { user } = useUser();
+
+ const result = React.useMemo(
+ () => findResult(user, round),
+ [round, user],
+ );
+
+ return <li className="group">
+ <h3 className="group-title">
+ {round.title ?
+ round.title
+ :
+ `${formatNumberAlways(tournament, round)} ${t('rounds.date', { date: new Date(round.created_at) })}`
+ }
+ </h3>
+ <div className="group-details">
+ {getStatusIcon(round, result, t)}
+ <div className="group-seed">
+ {round.code && round.code.length ?
+ <SeedCode code={round.code} game={round.game || 'alttpr'} />
+ : null}
+ <SeedButton round={round} tournament={tournament} />
+ </div>
+ <div className="group-result">
+ {mayReportResult(user, tournament) ?
+ <ReportButton round={round} tournament={tournament} user={user} />
+ : null}
+ <Badge round={round} tournament={tournament} user={user} />
+ </div>
+ </div>
+ </li>;
+};
+
+Item.propTypes = {
+ round: PropTypes.shape({
+ code: PropTypes.arrayOf(PropTypes.string),
+ created_at: PropTypes.string,
+ game: PropTypes.string,
+ id: PropTypes.number,
+ locked: PropTypes.bool,
+ number: PropTypes.number,
+ results: PropTypes.arrayOf(PropTypes.shape({
+ })),
+ seed: PropTypes.string,
+ title: PropTypes.string,
+ }),
+ tournament: PropTypes.shape({
+ participants: PropTypes.arrayOf(PropTypes.shape({
+ })),
+ id: PropTypes.number,
+ show_numbers: PropTypes.bool,
+ type: PropTypes.string,
+ }),
+};
+
+export default Item;
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Item from './Item';
+import LoadMore from '../rounds/LoadMore';
+import i18n from '../../i18n';
+
+const List = ({
+ loadMore,
+ rounds,
+ tournament,
+}) => rounds && rounds.length ? <>
+ <ol className="groups">
+ {rounds.map(round =>
+ <Item
+ key={round.id}
+ round={round}
+ tournament={tournament}
+ />
+ )}
+ </ol>
+ {loadMore ?
+ <LoadMore loadMore={loadMore} />
+ : null}
+</> :
+ <Alert variant="info">
+ {i18n.t('groups.empty')}
+ </Alert>
+;
+
+List.propTypes = {
+ loadMore: PropTypes.func,
+ rounds: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number,
+ })),
+ tournament: PropTypes.shape({
+ }),
+};
+
+export default withTranslation()(List);
: dateStr;
};
+const getEntryDetailsAssignee = entry => {
+ if (!entry || !entry.details || !entry.details.assignee) return 'Anonymous';
+ return getUserName(entry.details.assignee);
+};
+
const getEntryDetailsUsername = entry => {
if (!entry || !entry.details || !entry.details.user) return 'Anonymous';
return getUserName(entry.details.user);
`protocol.description.${entry.type}`,
{
...entry,
+ assignee: getEntryDetailsAssignee(entry),
picks: getEntryDetailsPicks(entry),
- username: getEntryDetailsUsername(entry),
},
);
case 'result.comment': {
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+import DetailDialog from './DetailDialog';
+import { getIcon, getTime } from '../../helpers/Result';
+import { maySeeResult } from '../../helpers/permissions';
+import { findResult } from '../../helpers/User';
+import { useUser } from '../../hooks/user';
+
+const getClassName = result => {
+ const classNames = ['result-badge', 'status'];
+ if (result && result.has_finished) {
+ classNames.push('finished');
+ if (result.comment) {
+ classNames.push('has-comment');
+ }
+ } else {
+ classNames.push('pending');
+ }
+ return classNames.join(' ');
+};
+
+const Badge = ({
+ round,
+ tournament,
+ user,
+}) => {
+ const [showDialog, setShowDialog] = React.useState(false);
+
+ const { user: authUser } = useUser();
+
+ const result = React.useMemo(
+ () => findResult(user, round),
+ [round, user],
+ );
+ const maySee = React.useMemo(
+ () => maySeeResult(authUser, tournament, round, result),
+ [authUser, result, round, tournament],
+ );
+
+ return <>
+ <Button
+ className={getClassName(result)}
+ onClick={() => setShowDialog(true)}
+ title={maySee && result && result.comment ? result.comment : null}
+ >
+ <span className="time">
+ {getTime(result, maySee)}
+ </span>
+ {getIcon(result, maySeeResult(authUser, tournament, round))}
+ </Button>
+ <DetailDialog
+ onHide={() => setShowDialog(false)}
+ round={round}
+ show={showDialog}
+ tournament={tournament}
+ user={user}
+ />
+ </>;
+};
+
+Badge.propTypes = {
+ round: PropTypes.shape({
+ }),
+ tournament: PropTypes.shape({
+ }),
+ user: PropTypes.shape({
+ }),
+};
+
+export default Badge;
import PropTypes from 'prop-types';
-import React, { useState } from 'react';
+import React from 'react';
import { Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
-import DetailDialog from './DetailDialog';
+import Badge from './Badge';
import Icon from '../common/Icon';
import Box from '../users/Box';
-import { getIcon, getTime } from '../../helpers/Result';
import { maySeeResult } from '../../helpers/permissions';
import { findResult } from '../../helpers/User';
import { useUser } from '../../hooks/user';
-const getClassName = result => {
- const classNames = ['status'];
- if (result && result.has_finished) {
- classNames.push('finished');
- if (result.comment) {
- classNames.push('has-comment');
- }
- } else {
- classNames.push('pending');
- }
- return classNames.join(' ');
-};
-
const twitchReg = /^https?:\/\/(www\.)?twitch\.tv/;
const youtubeReg = /^https?:\/\/(www\.)?youtu(\.be|be\.)/;
tournament,
user,
}) => {
- const [showDialog, setShowDialog] = useState(false);
-
const { t } = useTranslation();
const { user: authUser } = useUser();
return <div className="result">
<Box user={user} />
<div className="d-flex align-items-center justify-content-between">
- <Button
- className={getClassName(result)}
- onClick={() => setShowDialog(true)}
- title={maySee && result && result.comment ? result.comment : null}
- >
- <span className="time">
- {getTime(result, maySee)}
- </span>
- {getIcon(result, maySeeResult(authUser, tournament, round))}
- </Button>
+ <Badge round={round} tournament={tournament} user={user} />
{maySee && result && result.vod ?
<Button
className="vod-link"
</Button>
: null}
</div>
- <DetailDialog
- onHide={() => setShowDialog(false)}
- round={round}
- show={showDialog}
- tournament={tournament}
- user={user}
- />
</div>;
};
import Item from './Item';
import { sortByFinished, sortByResult } from '../../helpers/Participant';
import { maySeeResults } from '../../helpers/permissions';
-import { getRunners } from '../../helpers/Tournament';
+import { getRunners, hasFixedRunners } from '../../helpers/Tournament';
import { sortByTime, sortByUsername } from '../../helpers/Result';
import { useUser } from '../../hooks/user';
const List = ({ round, tournament }) => {
const { user } = useUser();
- if (tournament.type === 'open-async') {
- const results = maySeeResults(user, tournament, round)
- ? sortByTime(round.results || [])
- : sortByUsername(round.results || []);
+ if (hasFixedRunners(tournament)) {
+ const runners = maySeeResults(user, tournament, round)
+ ? sortByResult(getRunners(tournament), round)
+ : sortByFinished(getRunners(tournament), round);
return <div className="results d-flex flex-wrap">
- {results.map(result =>
+ {runners.map(participant =>
<Item
- key={result.id}
+ key={participant.id}
round={round}
tournament={tournament}
- user={result.user}
+ user={participant.user}
/>
)}
</div>;
}
- const runners = maySeeResults(user, tournament, round)
- ? sortByResult(getRunners(tournament), round)
- : sortByFinished(getRunners(tournament), round);
- return <div className="results d-flex flex-wrap">
- {runners.map(participant =>
+
+ const results = maySeeResults(user, tournament, round)
+ ? sortByTime(round.results || [])
+ : sortByUsername(round.results || []);
+ return <div className="results d-flex flex-wrap align-content-start">
+ {results.map(result =>
<Item
- key={participant.id}
+ key={result.id}
round={round}
tournament={tournament}
- user={participant.user}
+ user={result.user}
/>
)}
</div>;
isRunner,
} from '../../helpers/permissions';
import { formatNumber, isComplete } from '../../helpers/Round';
+import { hasFixedRunners } from '../../helpers/Tournament';
import { hasFinishedRound } from '../../helpers/User';
import { useUser } from '../../hooks/user';
</p>
: null}
<div className="bottom-half">
- {tournament.type === 'open-async' && round.results && round.results.length ?
+ {!hasFixedRunners(tournament) && round.results && round.results.length ?
<p>{t('rounds.numberOfResults', { count: round.results.length })}</p>
: null}
<div className="button-bar">
import ApplyButton from './ApplyButton';
import ExportButton from './ExportButton';
-import GroupInterface from './GroupInterface';
import Scoreboard from './Scoreboard';
import ScoreChartButton from './ScoreChartButton';
import SettingsButton from './SettingsButton';
import ApplicationsButton from '../applications/Button';
import Icon from '../common/Icon';
import RawHTML from '../common/RawHTML';
+import GroupInterface from '../groups/Interface';
import Protocol from '../protocol/Protocol';
import Rounds from '../rounds/List';
import Box from '../users/Box';
</div>
</Col>
<Col lg={{ order: 1, span: 8 }} xl={{ order: 1, span: 9 }}>
- {hasAssignedGroups(tournament) ?
- <GroupInterface
- selfAssign={actions.selfAssignGroups}
- tournament={tournament}
- />
- : null}
+ {hasAssignedGroups(tournament) ? (
+ <div className="mb-3">
+ <h2>{t('groups.heading')}</h2>
+ <GroupInterface
+ selfAssign={actions.selfAssignGroups}
+ tournament={tournament}
+ />
+ </div>
+ ): null}
<div className="d-flex align-items-center justify-content-between">
<h2>{t('rounds.heading')}</h2>
{actions.addRound && mayAddRounds(user, tournament) ?
+++ /dev/null
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import { missingGroupAssignment } from '../../helpers/Tournament';
-import { useUser } from '../../hooks/user';
-
-const GroupInterface = ({ selfAssign, tournament }) => {
- const { t } = useTranslation();
- const { user } = useUser();
-
- if (!user) {
- return <div><p>{t('groups.loginRequired')}</p></div>
- }
-
- if (missingGroupAssignment(tournament, user)) {
- return <div>
- <p>{t('groups.missingAssignments')}</p>
- <Button onClick={selfAssign}>
- {t('groups.selfAssignButton')}
- </Button>
- </div>
- }
-
- return <div>
- Groups here
- </div>;
-};
-
-GroupInterface.propTypes = {
- selfAssign: PropTypes.func,
- tournament: PropTypes.shape({
- }),
-};
-
-export default GroupInterface;
return tournament.participants.find(p => p.user_id == user.id);
};
+export const getAssignedRounds = (tournament, user) => {
+ if (!tournament?.group_assignments || !user) return [];
+ return tournament.rounds.filter(
+ (round) => tournament.group_assignments.find(
+ (ga) => round.number === ga.round_number && round.group === ga.group,
+ ),
+ );
+};
+
export const getPendingApplications = tournament => {
if (!tournament || !tournament.applications || !tournament.applications.length) return [];
return tournament.applications
export const hasAssignedGroups = tournament => (tournament?.type === 'open-grouped-async');
+export const hasFixedRunners = tournament => !['open-async', 'open-grouped-async'].includes(tournament?.type);
+
export const hasScoreboard = tournament => !!(tournament && tournament.type === 'signup-async');
export const hasSignup = tournament => !!(tournament && tournament.type === 'signup-async');
export const mayReportResult = (user, tournament) => {
if (!user || !tournament) return false;
- if (tournament.type === 'open-async') return true;
+ if (['open-async', 'open-grouped-async'].includes(tournament.type)) return true;
return isRunner(user, tournament);
};
uploading: 'Am Hochladen...',
},
groups: {
+ complete: 'Abgeschlossen',
+ heading: 'Gruppen',
loginRequired: 'Dieses Turnier nutzt Gruppenzuweisung. Bitte melde dich an, um deine Seeds zu laden.',
missingAssignments: 'Dieses Turnier nutzt Gruppenzuweisung. Falls du teilnehmen möchtest, hol dir bitter hier deine Zuweisungen ab.',
+ missingVod: 'VoD fehlt',
+ pendingResult: 'Ergebnis ausstehend',
selfAssignButton: 'Gruppen zuweisen',
selfAssignError: 'Fehler beim Zuweisen',
selfAssignSuccess: 'Gruppen zugewiesen',
rejected: 'Anmeldung von {{username}} abgelehnt',
},
group: {
- assign: 'Gruppen {{picks}} für {{username}} zugewiesen',
+ assign: 'Gruppen {{picks}} für {{assignee}} zugewiesen',
},
result: {
comment: 'Ergebnis von Runde {{number}} kommentiert: <1>{{comment}}</1>',
uploading: 'Uploading...',
},
groups: {
+ complete: 'Complete',
+ heading: 'Groups',
loginRequired: 'This tournament uses assigned groups. Please sign in to obtain your seeds.',
missingAssignments: 'This tournament uses assigned groups. If you want to participate, please grab your assignments here.',
+ missingVod: 'Missing VoD',
+ pendingResult: 'Pending result',
selfAssignButton: 'Assign groups',
selfAssignError: 'Error assigning groups',
selfAssignSuccess: 'Groups assigned',
rejected: 'Application from {{username}} rejected',
},
group: {
- assign: 'Assigned groups {{picks}} for {{username}}',
+ assign: 'Assigned groups {{picks}} for {{assignee}}',
},
result: {
comment: 'Result of round {{number}} commented: <1>{{comment}}</1>',
@import 'events';
@import 'form';
@import 'front';
+@import 'groups';
@import 'map';
@import 'participants';
@import 'results';
--- /dev/null
+.groups {
+ margin: 1rem 0;
+ padding: 0;
+
+ .group {
+ margin: 1rem 0;
+ border: $border-width solid $secondary;
+ border-radius: $border-radius;
+ background: $gray-700;
+ padding: 1ex;
+ list-style: none;
+ }
+
+ .group-details {
+ display: flex;
+ gap: 1em;
+ }
+ .group-result {
+ margin-left: auto;
+ text-align: right;
+ }
+ .seed-code {
+ margin-right: 1ex;
+ }
+ .result-badge {
+ margin-left: 1ex;
+ }
+}
+.result-badge {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5em;
+ min-width: 15ex;
+ border: none;
+ border-radius: 1ex;
+ background: $dark !important;
+ color: $light;
+ box-shadow: none !important;
+ transition: top 0.15s ease-in-out;
+
+ &.has-comment {
+ box-shadow: 0 0.5ex 0 $info;
+ }
+ &:active {
+ box-shadow: none;
+ top: 0.5ex;
+ }
+ &:focus {
+ outline: medium dashed $light;
+ }
+
+ .time {
+ min-width: 9ex;
+ height: 1.6em;
+ border-radius: 0.5ex;
+ padding: 0 0.5ex;
+ }
+ &.finished .time {
+ background: $secondary;
+ }
+}
+
.results {
.result {
padding: 1ex;
}
.status {
- display: flex;
- position: relative;
- align-items: center;
- justify-content: space-between;
margin-top: 1ex;
- padding: 0.5em;
width: 100%;
- min-width: 15ex;
- border: none;
- border-radius: 1ex;
- background: $dark;
- color: $light;
-
- box-shadow: none;
- transition: top 0.15s ease-in-out;
- &.has-comment {
- box-shadow: 0 0.5ex 0 $info;
- }
- &:active {
- box-shadow: none;
- top: 0.5ex;
- }
- &:focus {
- outline: medium dashed $light;
- }
- .time {
- min-width: 9ex;
- height: 1.6em;
- border-radius: 0.5ex;
- padding: 0 0.5ex;
- }
- &.finished .time {
- background: $secondary;
- }
}
.vod-link {