public function __construct(Result $result)
{
$this->result = $result;
- $result->load('user');
+ $result->load(['user', 'verified_by']);
}
/**
$request->user(),
);
DiscordBotCommand::queueResult($result);
- } else if ($result->wasChanged(['comment', 'vod'])) {
+ } elseif ($result->wasChanged(['comment', 'vod'])) {
Protocol::resultCommented(
$round->tournament,
$result,
<Icon.WARNING title="" />
</Button>;
}
+ if (result.verified_at) {
+ return <Button className="group-status" title={t('groups.verified')} variant="info">
+ <Icon.VERIFIED title="" />
+ </Button>;
+ }
return <Button className="group-status" title={t('groups.complete')} variant="success">
- <Icon.FINISHED />
+ <Icon.FINISHED title="" />
</Button>;
}
import PropTypes from 'prop-types';
import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
import Badge from './Badge';
-import Icon from '../common/Icon';
+import VodLink from './VodLink';
import Box from '../users/Box';
-import { maySeeResult } from '../../helpers/permissions';
+import { maySeeResult, mayVerifyResult } from '../../helpers/permissions';
import { findResult } from '../../helpers/User';
import { useUser } from '../../hooks/user';
-const twitchReg = /^https?:\/\/(www\.)?twitch\.tv/;
-const youtubeReg = /^https?:\/\/(www\.)?youtu(\.be|be\.)/;
-
-const getVoDVariant = result => {
- if (!result || !result.vod) return 'outline-secondary';
- if (twitchReg.test(result.vod)) {
- return 'twitch';
- }
- if (youtubeReg.test(result.vod)) {
- return 'outline-youtube';
- }
- return 'outline-secondary';
-};
-
-const getVoDIcon = result => {
- const variant = getVoDVariant(result);
- if (variant === 'twitch') {
- return <Icon.TWITCH title="" />;
- }
- if (variant === 'outline-youtube') {
- return <Icon.YOUTUBE title="" />;
- }
- return <Icon.VIDEO title="" />;
-};
-
const Item = ({
actions,
round,
tournament,
user,
}) => {
- const { t } = useTranslation();
const { user: authUser } = useUser();
const result = React.useMemo(
() => maySeeResult(authUser, tournament, round, result),
[authUser, result, round, tournament],
);
+ const mayVerify = React.useMemo(
+ () => mayVerifyResult(authUser, tournament, round, result),
+ [authUser, result, round, tournament],
+ );
return <div className="result">
<Box user={user} />
<div className="d-flex align-items-center justify-content-between">
<Badge actions={actions} round={round} tournament={tournament} user={user} />
- {maySee && result && result.vod ?
- <Button
- className="vod-link"
- href={result.vod}
- size="sm"
- target="_blank"
- title={t('results.vod')}
- variant={getVoDVariant(result)}
- >
- {getVoDIcon(result)}
- </Button>
+ {maySee || mayVerify ?
+ <VodLink result={result} />
: null}
</div>
</div>;
import React from 'react';
import Item from './Item';
-import { sortByFinished, sortByResult } from '../../helpers/Participant';
-import { maySeeResults } from '../../helpers/permissions';
-import { getRunners, hasFixedRunners } from '../../helpers/Tournament';
-import { sortByTime, sortByUsername } from '../../helpers/Result';
+import { compileResults } from '../../helpers/Round';
import { useUser } from '../../hooks/user';
const List = ({ actions, round, tournament }) => {
const { user } = useUser();
- 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">
- {runners.map(participant =>
- <Item
- actions={actions}
- key={participant.id}
- round={round}
- tournament={tournament}
- user={participant.user}
- />
- )}
- </div>;
- }
+ const results = React.useMemo(() => compileResults(tournament, round, user), [round, tournament, user]);
- 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
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Table } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import TableRow from './TableRow';
+import { maySeeResults } from '../../helpers/permissions';
+import { compileResults } from '../../helpers/Round';
+import { useUser } from '../../hooks/user';
+
+const ResultTable = ({ actions, round, tournament }) => {
+ const { t } = useTranslation();
+ const { user } = useUser();
+
+ const results = React.useMemo(
+ () => compileResults(tournament, round, user),
+ [round, tournament, user],
+ );
+ const maySee = React.useMemo(
+ () => maySeeResults(user, tournament, round),
+ [round, tournament, user],
+ );
+
+ return <Table className="result-table" striped hover>
+ <thead>
+ <tr>
+ <th className="result-runner">{t('results.runner')}</th>
+ {maySee ?
+ <th className="result-placement">{t('results.placement')}</th>
+ : null}
+ <th className="result-vod">{t('results.vod')}</th>
+ <th className="result-time">{t('results.result')}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {results.map((result) => (
+ <TableRow
+ actions={actions}
+ key={result.id}
+ round={round}
+ showPlacement={maySee}
+ tournament={tournament}
+ user={result.user}
+ />
+ ))}
+ </tbody>
+ </Table>;
+};
+
+ResultTable.propTypes = {
+ actions: PropTypes.shape({
+ }),
+ round: PropTypes.shape({
+ results: PropTypes.arrayOf(PropTypes.shape({
+ })),
+ }),
+ tournament: PropTypes.shape({
+ participants: PropTypes.arrayOf(PropTypes.shape({
+ })),
+ type: PropTypes.string,
+ users: PropTypes.arrayOf(PropTypes.shape({
+ })),
+ }),
+};
+
+export default ResultTable;
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import Badge from './Badge';
+import VodLink from './VodLink';
+import Box from '../users/Box';
+import { maySeeResult, mayVerifyResult } from '../../helpers/permissions';
+import { findResult } from '../../helpers/User';
+import { useUser } from '../../hooks/user';
+
+const TableRow = ({
+ actions,
+ round,
+ showPlacement = false,
+ tournament,
+ user,
+}) => {
+ const { t } = useTranslation();
+ 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],
+ );
+ const mayVerify = React.useMemo(
+ () => mayVerifyResult(authUser, tournament, round, result),
+ [authUser, result, round, tournament],
+ );
+
+ return <tr>
+ <td className="result-runner">
+ <Box user={user} />
+ </td>
+ {showPlacement ?
+ <td className="result-placement">
+ {maySee && result && result.placement
+ ? `${result.placement}. (${t('results.points', { count: result.score })})`
+ : t('results.pending')}
+ </td>
+ : null}
+ <td className="result-vod">
+ {maySee || mayVerify ?
+ <VodLink result={result} withLabel />
+ : null}
+ </td>
+ <td className="result-time">
+ <Badge actions={actions} round={round} tournament={tournament} user={user} />
+ </td>
+ </tr>;
+};
+
+TableRow.propTypes = {
+ actions: PropTypes.shape({
+ }),
+ round: PropTypes.shape({
+ }),
+ showPlacement: PropTypes.bool,
+ tournament: PropTypes.shape({
+ }),
+ user: PropTypes.shape({
+ }),
+};
+
+export default TableRow;
--- /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';
+
+const twitchReg = /^https?:\/\/(www\.)?twitch\.tv/;
+const youtubeReg = /^https?:\/\/(www\.)?youtu(\.be|be\.)/;
+
+const getVoDVariant = result => {
+ if (!result || !result.vod) return 'outline-secondary';
+ if (twitchReg.test(result.vod)) {
+ return 'twitch';
+ }
+ if (youtubeReg.test(result.vod)) {
+ return 'outline-youtube';
+ }
+ return 'outline-secondary';
+};
+
+const getVoDIcon = result => {
+ const variant = getVoDVariant(result);
+ if (variant === 'twitch') {
+ return <Icon.TWITCH title="" />;
+ }
+ if (variant === 'outline-youtube') {
+ return <Icon.YOUTUBE title="" />;
+ }
+ return <Icon.VIDEO title="" />;
+};
+
+const getVoDLabel = result => {
+ const variant = getVoDVariant(result);
+ if (variant === 'twitch') {
+ return 'icon.TwitchIcon';
+ }
+ if (variant === 'outline-youtube') {
+ return 'icon.YoutubeIcon';
+ }
+ return 'icon.VideoIcon';
+};
+
+const VodLink = ({
+ result,
+ withLabel = false,
+}) => {
+ const { t } = useTranslation();
+
+ if (!result?.vod) {
+ return null;
+ }
+
+ return <Button
+ className="vod-link"
+ href={result.vod}
+ size={withLabel ? 'md' : 'sm'}
+ target="_blank"
+ title={t('results.vod')}
+ variant={getVoDVariant(result)}
+ >
+ {getVoDIcon(result)}
+ {withLabel ?
+ <span className="ms-2">{t(getVoDLabel(result))}</span>
+ : null}
+ </Button>;
+};
+
+VodLink.propTypes = {
+ result: PropTypes.shape({
+ vod: PropTypes.string,
+ }),
+ withLabel: PropTypes.bool,
+};
+
+export default VodLink;
import RoundProtocol from '../protocol/RoundProtocol';
import List from '../results/List';
import ReportButton from '../results/ReportButton';
+import Table from '../results/Table';
import {
mayDeleteRound,
mayEditRound,
mayReportResult,
+ mayVerifyResults,
mayViewProtocol,
isRunner,
} from '../../helpers/permissions';
import { formatNumber, isComplete } from '../../helpers/Round';
-import { hasFixedRunners } from '../../helpers/Tournament';
+import { hasAssignedGroups, hasFixedRunners, requiresVerification } from '../../helpers/Tournament';
import { hasFinishedRound } from '../../helpers/User';
import { useUser } from '../../hooks/user';
const Item = ({
actions,
+ resultView = 'list',
round,
tournament,
}) => {
const { t } = useTranslation();
const { user } = useUser();
+ const mayVerify = React.useMemo(() => mayVerifyResults(user, tournament, round), [round, tournament, user]);
+ const unverifiedResults = React.useMemo(() => {
+ return (round.results || []).filter((result) => !result.verified_at).length;
+ }, [round]);
+
return <li className={getClassName(round, tournament, user)}>
{round.title ?
<h3>{round.title}</h3>
{formatNumber(tournament, round)}
{t('rounds.date', { date: new Date(round.created_at) })}
</p>
- <p className="seed">
- {round.code && round.code.length ?
- <>
- <SeedCode code={round.code} game={round.game || 'alttpr'} />
- <br />
- </>
- : null}
- <SeedButton
- round={round}
- tournament={tournament}
- />
- {' '}
- <SeedRolledBy round={round} />
- </p>
- {mayReportResult(user, tournament) ?
- <p className="report">
- <ReportButton
+ {!hasAssignedGroups(tournament) ? <>
+ <p className="seed">
+ {round.code && round.code.length ?
+ <>
+ <SeedCode code={round.code} game={round.game || 'alttpr'} />
+ <br />
+ </>
+ : null}
+ <SeedButton
round={round}
tournament={tournament}
- user={user}
/>
+ {' '}
+ <SeedRolledBy round={round} />
</p>
- : null}
+ {mayReportResult(user, tournament) ?
+ <p className="report">
+ <ReportButton
+ round={round}
+ tournament={tournament}
+ user={user}
+ />
+ </p>
+ : null}
+ </> : null}
<div className="bottom-half">
{!hasFixedRunners(tournament) && round.results && round.results.length ?
- <p>{t('rounds.numberOfResults', { count: round.results.length })}</p>
+ <p>
+ {t('rounds.numberOfResults', { count: round.results.length })}
+ {mayVerify && unverifiedResults && requiresVerification(tournament) ? <>
+ <br />
+ {t('rounds.numberOfUnverifiedResults', { count: unverifiedResults })}
+ </> : null}
+ </p>
: null}
<div className="button-bar">
<LockButton round={round} tournament={tournament} />
</div>
</div>
</div>
- <List actions={actions} round={round} tournament={tournament} />
+ {resultView === 'list' ?
+ <List actions={actions} round={round} tournament={tournament} />
+ :
+ <Table actions={actions} round={round} tournament={tournament} />
+ }
</div>
</li>;
};
Item.propTypes = {
actions: PropTypes.shape({
}),
+ resultView: PropTypes.string,
round: PropTypes.shape({
code: PropTypes.arrayOf(PropTypes.string),
created_at: PropTypes.string,
const List = ({
actions,
+ resultView,
rounds,
tournament,
}) => rounds && rounds.length ? <>
<Item
actions={actions}
key={round.id}
+ resultView={resultView}
round={round}
tournament={tournament}
/>
actions: PropTypes.shape({
moreRounds: PropTypes.func,
}),
+ resultView: PropTypes.string,
rounds: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.number,
})),
actions,
tournament,
}) => {
+ const [resultView, setResultView] = React.useState(localStorage.getItem('tournaments.resultView') || 'list');
+
const { t } = useTranslation();
const { user } = useUser();
+ const toggleResultView = React.useCallback(() => {
+ setResultView((oldView) => {
+ const newView = oldView === 'list' ? 'table' : 'list';
+ localStorage.setItem('tournaments.resultView', newView);
+ return newView;
+ })
+ }, []);
+
return <Container className={getClassName(tournament, user)} fluid>
<Row>
<Col lg={8} xl={9}>
): null}
<div className="d-flex align-items-center justify-content-between">
<h2>{t('rounds.heading')}</h2>
- {actions.addRound && mayAddRounds(user, tournament) ?
- <Button onClick={actions.addRound}>
- {t('rounds.new')}
+ <div className="button-bar">
+ <Button onClick={toggleResultView} variant="outline-secondary">
+ {t(`results.${resultView}`)}
</Button>
- : null}
+ {actions.addRound && mayAddRounds(user, tournament) ?
+ <Button onClick={actions.addRound}>
+ {t('rounds.new')}
+ </Button>
+ : null}
+ </div>
</div>
{tournament.rounds ?
<Rounds
actions={actions}
+ resultView={resultView}
rounds={tournament.rounds}
tournament={tournament}
/>
if (result.placement === 3 && maySee) {
return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
}
+ if (result.verified_at) {
+ return <Icon.VERIFIED className="text-info" size="lg" />;
+ }
return <Icon.FINISHED className="text-success" size="lg" />;
};
getIcon,
getTime,
parseTime,
+ sortByTime,
+ sortByUsername,
};
import Participant from './Participant';
+import { maySeeResults } from './permissions';
+import Result from './Result';
import Tournament from './Tournament';
+export const compileResults = (tournament, round, user) => {
+ if (Tournament.hasFixedRunners(tournament)) {
+ const runners = Tournament.getRunners(tournament);
+ if (maySeeResults(user, tournament, round)) {
+ return Participant.sortByResult(runners, round);
+ }
+ return Participant.sortByFinished(runners, round);
+ }
+ if (maySeeResults(user, tournament, round)) {
+ return Result.sortByTime(round.results || []);
+ }
+ return Result.sortByUsername(round.results || []);
+}
+
export const formatNumberAlways = (tournament, round) => {
const group = (tournament?.group_size > 1 && round?.group) || '';
return round?.number ? `#${round.number}${group} ` : `X${round.id}`;
if (users.length > tournament.rounds.length) {
summary.addRow([
i18n.t('results.runner'),
+ i18n.t('users.discordId'),
+ i18n.t('users.discordTag'),
...tournament.rounds.map((round) => round.title || Round.formatNumberAlways(tournament, round)),
]);
users.forEach((user) => {
summary.addRow([
User.getUserName(user),
+ user.id,
+ user.username,
...tournament.rounds.map((round) => Result.getTime(User.findResult(user, round), true)),
]);
});
};
};
+export const requiresVerification = tournament => (tournament?.type === 'open-grouped-async');
+
export const sortParticipants = tournament => {
if (!tournament || !tournament.participants || !tournament.participants.length) {
return tournament;
getTournamentAdmins,
getTournamentCrew,
getTournamentMonitors,
+ hasAssignedGroups,
+ hasFixedRunners,
hasRunners,
hasScoreboard,
hasSignup,
patchResult,
patchRound,
patchUser,
+ requiresVerification,
sortParticipants,
};
selfAssignError: 'Fehler beim Zuweisen',
selfAssignSuccess: 'Gruppen zugewiesen',
tournamentClosed: 'Dieses Turnier ist geschlossen.',
+ verified: 'Bestätigt',
},
icon: {
AddIcon: 'Hinzufügen',
edit: 'Ergebnis ändern',
editComment: 'Kommentar ändern',
forfeit: 'Aufgegeben',
+ list: 'Liste',
pending: 'Ausstehend',
placement: 'Platzierung',
points_one: '{{ count }} Punkt',
round: 'Runde',
runner: 'Runner',
score: 'Punkte',
+ table: 'Tabelle',
time: 'Zeit: {{ time }}',
verification: 'Verifikation',
verificationPending: 'Noch nicht verifiziert',
noSeed: 'Noch kein Seed',
numberOfResults_one: '{{ count }} Ergebnis',
numberOfResults_other: '{{ count }} Ergebnisse',
+ numberOfUnverifiedResults: '{{ count }} unbestätigt',
loadMore: 'weitere Runden laden',
lock: 'Runde sperren',
lockDescription: 'Wenn die Runde gesperrt wird, können Runner keine Änderungen an ihrem Ergebnis mehr vornehmen.',
p2: 'Für die Anzeige des Leaderboards wird eine Anfrage an alttp.localhorst.tv gesendet. Diese Anfrage wird anonymisiert protokolliert und nicht weiter verwertet.',
},
users: {
+ discordId: 'Discord ID',
discordTag: 'Discord Tag',
editNickname: 'Name bearbeiten',
editStreamLink: 'Stream Link bearbeiten',
selfAssignError: 'Error assigning groups',
selfAssignSuccess: 'Groups assigned',
tournamentClosed: 'This tournament has closed.',
+ verified: 'Verified',
},
icon: {
AddIcon: 'Add',
edit: 'Change result',
editComment: 'Edit comment',
forfeit: 'Forfeit',
+ list: 'List',
pending: 'Pending',
placement: 'Placement',
points_one: '{{ count }} point',
round: 'Round',
runner: 'Runner',
score: 'Score',
+ table: 'Table',
time: 'Time: {{ time }}',
verification: 'Verification',
verificationPending: 'Pending verification',
noSeed: 'No seed set',
numberOfResults_one: '{{ count }} submission',
numberOfResults_other: '{{ count }} submissions',
+ numberOfUnverifiedResults: '{{ count }} unverified',
loadMore: 'load more rounds',
lock: 'Lock round',
lockDescription: 'When a round is locked, runners cannot submit or change results.',
p2: 'To display the leaderboard, a request is made to alttp.localhorst.tv. This request is logged anonymously and not further processed.',
},
users: {
+ discordId: 'Discord ID',
discordTag: 'Discord tag',
editNickname: 'Edit name',
editStreamLink: 'Edit stream link',
transition: top 0.15s ease-in-out;
&.has-comment {
- box-shadow: 0 0.5ex 0 $info;
+ box-shadow: 0 0.5ex 0 $info !important;
}
&:active {
box-shadow: none;
}
}
+.result-table {
+ width: 100%;
+
+ .result-time,
+ .result-vod {
+ text-align: right;
+ width: 15ex;
+ }
+}
+
.results {
.result {
padding: 1ex;