From: Daniel Karbach Date: Wed, 26 Nov 2025 14:23:30 +0000 (+0100) Subject: result table view X-Git-Url: https://git.localhorst.tv/?a=commitdiff_plain;h=a0304c5a4528ba64a1965c73fd1cc388cd59d9f0;p=alttp.git result table view --- diff --git a/app/Events/ResultChanged.php b/app/Events/ResultChanged.php index da93768..61207a0 100644 --- a/app/Events/ResultChanged.php +++ b/app/Events/ResultChanged.php @@ -23,7 +23,7 @@ class ResultChanged implements ShouldBroadcast public function __construct(Result $result) { $this->result = $result; - $result->load('user'); + $result->load(['user', 'verified_by']); } /** diff --git a/app/Http/Controllers/ResultController.php b/app/Http/Controllers/ResultController.php index 004ba6b..6a4eb59 100644 --- a/app/Http/Controllers/ResultController.php +++ b/app/Http/Controllers/ResultController.php @@ -55,7 +55,7 @@ class ResultController extends Controller $request->user(), ); DiscordBotCommand::queueResult($result); - } else if ($result->wasChanged(['comment', 'vod'])) { + } elseif ($result->wasChanged(['comment', 'vod'])) { Protocol::resultCommented( $round->tournament, $result, diff --git a/resources/js/components/groups/Item.jsx b/resources/js/components/groups/Item.jsx index f887e91..1322307 100644 --- a/resources/js/components/groups/Item.jsx +++ b/resources/js/components/groups/Item.jsx @@ -24,8 +24,13 @@ const getStatusIcon = (round, result, t) => { ; } + if (result.verified_at) { + return ; + } return ; } diff --git a/resources/js/components/results/Item.jsx b/resources/js/components/results/Item.jsx index 730bfeb..527553a 100644 --- a/resources/js/components/results/Item.jsx +++ b/resources/js/components/results/Item.jsx @@ -1,47 +1,19 @@ 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 ; - } - if (variant === 'outline-youtube') { - return ; - } - return ; -}; - const Item = ({ actions, round, tournament, user, }) => { - const { t } = useTranslation(); const { user: authUser } = useUser(); const result = React.useMemo( @@ -52,22 +24,17 @@ const Item = ({ () => maySeeResult(authUser, tournament, round, result), [authUser, result, round, tournament], ); + const mayVerify = React.useMemo( + () => mayVerifyResult(authUser, tournament, round, result), + [authUser, result, round, tournament], + ); return
- {maySee && result && result.vod ? - + {maySee || mayVerify ? + : null}
; diff --git a/resources/js/components/results/List.jsx b/resources/js/components/results/List.jsx index 0fa1e03..ac174ee 100644 --- a/resources/js/components/results/List.jsx +++ b/resources/js/components/results/List.jsx @@ -2,35 +2,14 @@ import PropTypes from 'prop-types'; 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
- {runners.map(participant => - - )} -
; - } + const results = React.useMemo(() => compileResults(tournament, round, user), [round, tournament, user]); - const results = maySeeResults(user, tournament, round) - ? sortByTime(round.results || []) - : sortByUsername(round.results || []); return
{results.map(result => { + 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 + + + + {maySee ? + + : null} + + + + + + {results.map((result) => ( + + ))} + +
{t('results.runner')}{t('results.placement')}{t('results.vod')}{t('results.result')}
; +}; + +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; diff --git a/resources/js/components/results/TableRow.jsx b/resources/js/components/results/TableRow.jsx new file mode 100644 index 0000000..c024bff --- /dev/null +++ b/resources/js/components/results/TableRow.jsx @@ -0,0 +1,69 @@ +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 + + + + {showPlacement ? + + {maySee && result && result.placement + ? `${result.placement}. (${t('results.points', { count: result.score })})` + : t('results.pending')} + + : null} + + {maySee || mayVerify ? + + : null} + + + + + ; +}; + +TableRow.propTypes = { + actions: PropTypes.shape({ + }), + round: PropTypes.shape({ + }), + showPlacement: PropTypes.bool, + tournament: PropTypes.shape({ + }), + user: PropTypes.shape({ + }), +}; + +export default TableRow; diff --git a/resources/js/components/results/VodLink.jsx b/resources/js/components/results/VodLink.jsx new file mode 100644 index 0000000..5eff825 --- /dev/null +++ b/resources/js/components/results/VodLink.jsx @@ -0,0 +1,76 @@ +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 ; + } + if (variant === 'outline-youtube') { + return ; + } + return ; +}; + +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 ; +}; + +VodLink.propTypes = { + result: PropTypes.shape({ + vod: PropTypes.string, + }), + withLabel: PropTypes.bool, +}; + +export default VodLink; diff --git a/resources/js/components/rounds/Item.jsx b/resources/js/components/rounds/Item.jsx index 45f2c85..28c86b7 100644 --- a/resources/js/components/rounds/Item.jsx +++ b/resources/js/components/rounds/Item.jsx @@ -11,15 +11,17 @@ import SeedRolledBy from './SeedRolledBy'; 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'; @@ -45,12 +47,18 @@ const getClassName = (round, tournament, 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
  • {round.title ?

    {round.title}

    @@ -61,32 +69,40 @@ const Item = ({ {formatNumber(tournament, round)} {t('rounds.date', { date: new Date(round.created_at) })}

    -

    - {round.code && round.code.length ? - <> - -
    - - : null} - - {' '} - -

    - {mayReportResult(user, tournament) ? -

    - +

    + {round.code && round.code.length ? + <> + +
    + + : null} + + {' '} +

    - : null} + {mayReportResult(user, tournament) ? +

    + +

    + : null} + : null}
    {!hasFixedRunners(tournament) && round.results && round.results.length ? -

    {t('rounds.numberOfResults', { count: round.results.length })}

    +

    + {t('rounds.numberOfResults', { count: round.results.length })} + {mayVerify && unverifiedResults && requiresVerification(tournament) ? <> +
    + {t('rounds.numberOfUnverifiedResults', { count: unverifiedResults })} + : null} +

    : null}
    @@ -102,7 +118,11 @@ const Item = ({
  • - + {resultView === 'list' ? + + : + + } ; }; @@ -110,6 +130,7 @@ const Item = ({ Item.propTypes = { actions: PropTypes.shape({ }), + resultView: PropTypes.string, round: PropTypes.shape({ code: PropTypes.arrayOf(PropTypes.string), created_at: PropTypes.string, diff --git a/resources/js/components/rounds/List.jsx b/resources/js/components/rounds/List.jsx index c4a0293..dc343e1 100644 --- a/resources/js/components/rounds/List.jsx +++ b/resources/js/components/rounds/List.jsx @@ -9,6 +9,7 @@ import i18n from '../../i18n'; const List = ({ actions, + resultView, rounds, tournament, }) => rounds && rounds.length ? <> @@ -17,6 +18,7 @@ const List = ({ @@ -35,6 +37,7 @@ List.propTypes = { actions: PropTypes.shape({ moreRounds: PropTypes.func, }), + resultView: PropTypes.string, rounds: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.number, })), diff --git a/resources/js/components/tournament/Detail.jsx b/resources/js/components/tournament/Detail.jsx index af339be..747ed7a 100644 --- a/resources/js/components/tournament/Detail.jsx +++ b/resources/js/components/tournament/Detail.jsx @@ -52,9 +52,19 @@ const Detail = ({ 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 @@ -143,15 +153,21 @@ const Detail = ({ ): null}

    {t('rounds.heading')}

    - {actions.addRound && mayAddRounds(user, tournament) ? - - : null} + {actions.addRound && mayAddRounds(user, tournament) ? + + : null} +
    {tournament.rounds ? diff --git a/resources/js/helpers/Result.jsx b/resources/js/helpers/Result.jsx index ed16f38..0024e9f 100644 --- a/resources/js/helpers/Result.jsx +++ b/resources/js/helpers/Result.jsx @@ -55,6 +55,9 @@ export const getIcon = (result, maySee) => { if (result.placement === 3 && maySee) { return ; } + if (result.verified_at) { + return ; + } return ; }; @@ -87,4 +90,6 @@ export default { getIcon, getTime, parseTime, + sortByTime, + sortByUsername, }; diff --git a/resources/js/helpers/Round.js b/resources/js/helpers/Round.js index dd349de..c12d41d 100644 --- a/resources/js/helpers/Round.js +++ b/resources/js/helpers/Round.js @@ -1,6 +1,22 @@ 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}`; diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js index c589ed4..206b15c 100644 --- a/resources/js/helpers/Tournament.js +++ b/resources/js/helpers/Tournament.js @@ -87,11 +87,15 @@ export const exportXlsx = async (tnmt) => { 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)), ]); }); @@ -338,6 +342,8 @@ export const removeApplication = (tournament, id) => { }; }; +export const requiresVerification = tournament => (tournament?.type === 'open-grouped-async'); + export const sortParticipants = tournament => { if (!tournament || !tournament.participants || !tournament.participants.length) { return tournament; @@ -355,6 +361,8 @@ export default { getTournamentAdmins, getTournamentCrew, getTournamentMonitors, + hasAssignedGroups, + hasFixedRunners, hasRunners, hasScoreboard, hasSignup, @@ -364,5 +372,6 @@ export default { patchResult, patchRound, patchUser, + requiresVerification, sortParticipants, }; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 4ee602b..6439379 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -390,6 +390,7 @@ export default { selfAssignError: 'Fehler beim Zuweisen', selfAssignSuccess: 'Gruppen zugewiesen', tournamentClosed: 'Dieses Turnier ist geschlossen.', + verified: 'Bestätigt', }, icon: { AddIcon: 'Hinzufügen', @@ -613,6 +614,7 @@ export default { edit: 'Ergebnis ändern', editComment: 'Kommentar ändern', forfeit: 'Aufgegeben', + list: 'Liste', pending: 'Ausstehend', placement: 'Platzierung', points_one: '{{ count }} Punkt', @@ -626,6 +628,7 @@ export default { round: 'Runde', runner: 'Runner', score: 'Punkte', + table: 'Tabelle', time: 'Zeit: {{ time }}', verification: 'Verifikation', verificationPending: 'Noch nicht verifiziert', @@ -653,6 +656,7 @@ export default { 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.', @@ -1060,6 +1064,7 @@ export default { 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', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 945b2a1..ae5442d 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -390,6 +390,7 @@ export default { selfAssignError: 'Error assigning groups', selfAssignSuccess: 'Groups assigned', tournamentClosed: 'This tournament has closed.', + verified: 'Verified', }, icon: { AddIcon: 'Add', @@ -613,6 +614,7 @@ export default { edit: 'Change result', editComment: 'Edit comment', forfeit: 'Forfeit', + list: 'List', pending: 'Pending', placement: 'Placement', points_one: '{{ count }} point', @@ -626,6 +628,7 @@ export default { round: 'Round', runner: 'Runner', score: 'Score', + table: 'Table', time: 'Time: {{ time }}', verification: 'Verification', verificationPending: 'Pending verification', @@ -653,6 +656,7 @@ export default { 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.', @@ -1060,6 +1064,7 @@ export default { 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', diff --git a/resources/sass/results.scss b/resources/sass/results.scss index 99fa5cf..ea46587 100644 --- a/resources/sass/results.scss +++ b/resources/sass/results.scss @@ -19,7 +19,7 @@ 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; @@ -40,6 +40,16 @@ } } +.result-table { + width: 100%; + + .result-time, + .result-vod { + text-align: right; + width: 15ex; + } +} + .results { .result { padding: 1ex;