From ef33210da2d880ae0383fbd22d0a1fb62a9c1695 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Tue, 25 Nov 2025 12:17:29 +0100 Subject: [PATCH] basic group interface implementation --- .../Interface.jsx} | 9 +- resources/js/components/groups/Item.jsx | 92 +++++++++++++++++++ resources/js/components/groups/List.jsx | 42 +++++++++ resources/js/components/protocol/Item.jsx | 7 +- resources/js/components/results/Badge.jsx | 72 +++++++++++++++ resources/js/components/results/Item.jsx | 38 +------- resources/js/components/results/List.jsx | 31 ++++--- resources/js/components/rounds/Item.jsx | 3 +- resources/js/components/tournament/Detail.jsx | 17 ++-- resources/js/helpers/Tournament.js | 11 +++ resources/js/helpers/permissions.js | 2 +- resources/js/i18n/de.js | 6 +- resources/js/i18n/en.js | 6 +- resources/sass/app.scss | 1 + resources/sass/groups.scss | 28 ++++++ resources/sass/results.scss | 68 +++++++------- 16 files changed, 335 insertions(+), 98 deletions(-) rename resources/js/components/{tournament/GroupInterface.jsx => groups/Interface.jsx} (72%) create mode 100644 resources/js/components/groups/Item.jsx create mode 100644 resources/js/components/groups/List.jsx create mode 100644 resources/js/components/results/Badge.jsx create mode 100644 resources/sass/groups.scss diff --git a/resources/js/components/tournament/GroupInterface.jsx b/resources/js/components/groups/Interface.jsx similarity index 72% rename from resources/js/components/tournament/GroupInterface.jsx rename to resources/js/components/groups/Interface.jsx index aed89d2..e16e9d7 100644 --- a/resources/js/components/tournament/GroupInterface.jsx +++ b/resources/js/components/groups/Interface.jsx @@ -3,13 +3,16 @@ import React from 'react'; import { Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { missingGroupAssignment } from '../../helpers/Tournament'; +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

{t('groups.loginRequired')}

} @@ -23,9 +26,7 @@ const GroupInterface = ({ selfAssign, tournament }) => { } - return
- Groups here -
; + return ; }; GroupInterface.propTypes = { diff --git a/resources/js/components/groups/Item.jsx b/resources/js/components/groups/Item.jsx new file mode 100644 index 0000000..a7b3a23 --- /dev/null +++ b/resources/js/components/groups/Item.jsx @@ -0,0 +1,92 @@ +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 ; + } + if (!result.vod) { + return ; + } + return ; +} + +const Item = ({ + round, + tournament, +}) => { + const { t } = useTranslation(); + const { user } = useUser(); + + const result = React.useMemo( + () => findResult(user, round), + [round, user], + ); + + return
  • +

    + {round.title ? + round.title + : + `${formatNumberAlways(tournament, round)} ${t('rounds.date', { date: new Date(round.created_at) })}` + } +

    +
    + {getStatusIcon(round, result, t)} +
    + {round.code && round.code.length ? + + : null} + +
    +
    + {mayReportResult(user, tournament) ? + + : null} + +
    +
    +
  • ; +}; + +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; diff --git a/resources/js/components/groups/List.jsx b/resources/js/components/groups/List.jsx new file mode 100644 index 0000000..e69c8ff --- /dev/null +++ b/resources/js/components/groups/List.jsx @@ -0,0 +1,42 @@ +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 ? <> +
      + {rounds.map(round => + + )} +
    + {loadMore ? + + : null} + : + + {i18n.t('groups.empty')} + +; + +List.propTypes = { + loadMore: PropTypes.func, + rounds: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number, + })), + tournament: PropTypes.shape({ + }), +}; + +export default withTranslation()(List); diff --git a/resources/js/components/protocol/Item.jsx b/resources/js/components/protocol/Item.jsx index 29bec88..e32c28f 100644 --- a/resources/js/components/protocol/Item.jsx +++ b/resources/js/components/protocol/Item.jsx @@ -16,6 +16,11 @@ const getEntryDate = entry => { : 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); @@ -62,8 +67,8 @@ const getEntryDescription = (entry, t) => { `protocol.description.${entry.type}`, { ...entry, + assignee: getEntryDetailsAssignee(entry), picks: getEntryDetailsPicks(entry), - username: getEntryDetailsUsername(entry), }, ); case 'result.comment': { diff --git a/resources/js/components/results/Badge.jsx b/resources/js/components/results/Badge.jsx new file mode 100644 index 0000000..1339d82 --- /dev/null +++ b/resources/js/components/results/Badge.jsx @@ -0,0 +1,72 @@ +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 <> + + setShowDialog(false)} + round={round} + show={showDialog} + tournament={tournament} + user={user} + /> + ; +}; + +Badge.propTypes = { + round: PropTypes.shape({ + }), + tournament: PropTypes.shape({ + }), + user: PropTypes.shape({ + }), +}; + +export default Badge; diff --git a/resources/js/components/results/Item.jsx b/resources/js/components/results/Item.jsx index da0c99e..b3a0b89 100644 --- a/resources/js/components/results/Item.jsx +++ b/resources/js/components/results/Item.jsx @@ -1,29 +1,15 @@ 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\.)/; @@ -54,8 +40,6 @@ const Item = ({ tournament, user, }) => { - const [showDialog, setShowDialog] = useState(false); - const { t } = useTranslation(); const { user: authUser } = useUser(); @@ -71,16 +55,7 @@ const Item = ({ return
    - + {maySee && result && result.vod ?
    - setShowDialog(false)} - round={round} - show={showDialog} - tournament={tournament} - user={user} - />
    ; }; diff --git a/resources/js/components/results/List.jsx b/resources/js/components/results/List.jsx index f26a131..06d34d8 100644 --- a/resources/js/components/results/List.jsx +++ b/resources/js/components/results/List.jsx @@ -4,38 +4,39 @@ import React from 'react'; 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
    - {results.map(result => + {runners.map(participant => )}
    ; } - const runners = maySeeResults(user, tournament, round) - ? sortByResult(getRunners(tournament), round) - : sortByFinished(getRunners(tournament), round); - return
    - {runners.map(participant => + + const results = maySeeResults(user, tournament, round) + ? sortByTime(round.results || []) + : sortByUsername(round.results || []); + return
    + {results.map(result => )}
    ; diff --git a/resources/js/components/rounds/Item.jsx b/resources/js/components/rounds/Item.jsx index 39f4586..2d207f2 100644 --- a/resources/js/components/rounds/Item.jsx +++ b/resources/js/components/rounds/Item.jsx @@ -19,6 +19,7 @@ import { 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'; @@ -83,7 +84,7 @@ const Item = ({

    : null}
    - {tournament.type === 'open-async' && round.results && round.results.length ? + {!hasFixedRunners(tournament) && round.results && round.results.length ?

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

    : null}
    diff --git a/resources/js/components/tournament/Detail.jsx b/resources/js/components/tournament/Detail.jsx index 281a245..2407d9b 100644 --- a/resources/js/components/tournament/Detail.jsx +++ b/resources/js/components/tournament/Detail.jsx @@ -5,13 +5,13 @@ import { useTranslation } from 'react-i18next'; 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'; @@ -132,12 +132,15 @@ const Detail = ({
    - {hasAssignedGroups(tournament) ? - - : null} + {hasAssignedGroups(tournament) ? ( +
    +

    {t('groups.heading')}

    + +
    + ): null}

    {t('rounds.heading')}

    {actions.addRound && mayAddRounds(user, tournament) ? diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js index a96ab36..c589ed4 100644 --- a/resources/js/helpers/Tournament.js +++ b/resources/js/helpers/Tournament.js @@ -149,6 +149,15 @@ export const findParticipant = (tournament, user) => { 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 @@ -165,6 +174,8 @@ export const getRunners = tournament => { 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'); diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index d1d1952..0c1aaf6 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -157,7 +157,7 @@ export const mayHandleApplications = (user, tournament) => 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); }; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 5497a0b..6599062 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -380,8 +380,12 @@ export default { 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', @@ -569,7 +573,7 @@ export default { 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}}', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index a3a3c3f..7638aa7 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -380,8 +380,12 @@ export default { 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', @@ -569,7 +573,7 @@ export default { 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}}', diff --git a/resources/sass/app.scss b/resources/sass/app.scss index 910838f..c7b80b8 100644 --- a/resources/sass/app.scss +++ b/resources/sass/app.scss @@ -14,6 +14,7 @@ @import 'events'; @import 'form'; @import 'front'; +@import 'groups'; @import 'map'; @import 'participants'; @import 'results'; diff --git a/resources/sass/groups.scss b/resources/sass/groups.scss new file mode 100644 index 0000000..d76df89 --- /dev/null +++ b/resources/sass/groups.scss @@ -0,0 +1,28 @@ +.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; + } +} diff --git a/resources/sass/results.scss b/resources/sass/results.scss index b1ef053..5af982d 100644 --- a/resources/sass/results.scss +++ b/resources/sass/results.scss @@ -1,3 +1,39 @@ +.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; @@ -7,41 +43,9 @@ } .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 { -- 2.47.3