diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3d19a63..5af8d13 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,3 +9,9 @@ variables: include: - project: 'turniere/turniere-infra' file: '/ci/pipeline.yaml' + +eslint: + stage: test + script: + - yarn install + - yarn eslint . diff --git a/README.md b/README.md index 48d6f6b..de9874c 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,7 @@ $ docker build -t turniere-frontend . ``` The built container exposes port 80. + +# Todo + +Timer in topnav \ No newline at end of file diff --git a/js/components/FavoriteBar.js b/js/components/FavoriteBar.js new file mode 100644 index 0000000..86fd731 --- /dev/null +++ b/js/components/FavoriteBar.js @@ -0,0 +1,192 @@ +import React, {useState, useEffect, useRef} from 'react'; +import {Button, ButtonGroup, Input} from 'reactstrap'; +import {FaHeartCirclePlus, FaRegHeart, FaHeart, FaArrowTurnDown} from 'react-icons/fa6'; + +export function FavoriteBar({teams}) { + const [favorite, setFavorite] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isPulsing, setIsPulsing] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const headingRef = useRef(null); + const favoriteBarRef = useRef(null); + const scrollButtonRef = useRef(null); + + useEffect(() => { + const savedFavorite = localStorage.getItem('favoriteTeam'); + if (savedFavorite) { + const team = teams.find(team => team.id === parseInt(savedFavorite, 10)); + if (team) { + setFavorite(team); + } + } + setIsLoading(false); + }, [teams]); + + useEffect(() => { + if (isVisible && favoriteBarRef.current) { + favoriteBarRef.current.style.maxHeight = `${favoriteBarRef.current.scrollHeight}px`; + } else if (favoriteBarRef.current) { + favoriteBarRef.current.style.maxHeight = '0'; + } + }, [isVisible]); + + useEffect(() => { + if (favoriteBarRef.current) { + favoriteBarRef.current.style.maxHeight = `${favoriteBarRef.current.scrollHeight}px`; + } + }, [searchQuery]); + + const toggleFavorite = team => { + if (favorite && favorite.id === team.id) { + setFavorite(null); + localStorage.removeItem('favoriteTeam'); + } else { + setFavorite(team); + localStorage.setItem('favoriteTeam', team.id); + setIsPulsing(true); + headingRef.current.scrollIntoView({behavior: 'smooth', block: 'center'}); + if (scrollButtonRef.current) { + scrollButtonRef.current.focus(); + } + } + setIsVisible(false); // Close the favorite menu + }; + + function findLowestPlayoffParticipation(favoriteId) { + const matchesWithFavoriteParticipation = document.querySelectorAll(`[data-team-level-ids*='-${favoriteId}']`); + let lowestMatch = null; // lowest means lowest stage number > latest game of the favorite + let lowestStageNum = Infinity; + + // Iterate over each element to find the match with the lowest stage number + matchesWithFavoriteParticipation.forEach(el => { + const dataTeamLevelIds = el.getAttribute('data-team-level-ids').split(','); + dataTeamLevelIds.forEach(pair => { + const [stage, teamId] = pair.split('-').map(Number); + if (teamId === favorite.id && stage < lowestStageNum) { + lowestStageNum = stage; + lowestMatch = el; + } + }); + }); + return lowestMatch; + } + + function findScrollToGroup(favoriteId) { + // Look for group elements that contain the favorite team's ID + const groupElements = document.querySelectorAll('[data-teams]'); + const scrollToNotFound = null; + + groupElements.forEach(groupEl => { + const teamIds = groupEl.getAttribute('data-teams').split(',').map(id => parseInt(id, 10)); + if (teamIds.includes(favoriteId)) { + return groupEl; + } + }); + + return scrollToNotFound; + } + + const scrollToFavorite = () => { + if (!favorite) { + return; // Exit if there is no favorite team selected + } + + const lowestMatch = findLowestPlayoffParticipation(favorite.id); + + let scrollTo; + if (lowestMatch) { + scrollTo = lowestMatch; + } else { + scrollTo = findScrollToGroup(favorite.id); + } + + if (!scrollTo) { + console.error('No match or group found for the favorite team'); + return; + } + + let scrollTimeout; + const handleScroll = () => { + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { + setIsPulsing(false); + scrollTo.classList.add('scroll-to-highlight'); + setTimeout(() => { + scrollTo.classList.remove('scroll-to-highlight'); + }, 2000); + window.removeEventListener('scroll', handleScroll); + }, 100); + }; + + scrollTo.scrollIntoView({behavior: 'smooth', block: 'center'}); // Smoothly scroll to the target element + + // Add a scroll event listener to start the highlighting after scrolling only + window.addEventListener('scroll', handleScroll); + }; + + if (isLoading) { + return
Loading...
; + } + + const sortedTeams = [...teams].sort((a, b) => a.name.localeCompare(b.name)); + const filteredTeams = sortedTeams.filter(team => team.name.toLowerCase().includes(searchQuery.toLowerCase())); + + return ( +
+
+

Favorit:

+

{favorite ? favorite.name : ''}

+ + + {favorite && ( + + )} + +
+
+ {sortedTeams.length > 5 && ( + setSearchQuery(e.target.value)} + className="mb-2" + /> + )} +
+ {filteredTeams.map(team => ( +
toggleFavorite(team)} + style={{display: 'flex', alignItems: 'center', cursor: 'pointer'}} + > + + {team.name} +
+ ))} +
+
+
+ ); +} diff --git a/js/components/GroupStage.js b/js/components/GroupStage.js index 0efef75..f546a46 100644 --- a/js/components/GroupStage.js +++ b/js/components/GroupStage.js @@ -4,56 +4,74 @@ import React, {Component} from 'react'; import {getGroup} from '../redux/tournamentApi'; import {notify} from 'react-notify-toast'; import {sortMatchesByPositionAscending} from '../utils/sorting'; +import {FaChevronDown} from 'react-icons/fa6'; export default class GroupStage extends Component { constructor(props) { super(props); - this.state = {showMatches: this.props.showMatches}; - this.toggleShowMatches = this.toggleShowMatches.bind(this); - } - - toggleShowMatches() { - this.setState({showMatches: !this.state.showMatches}); + this.groupRefs = this.props.groups.reduce((acc, group) => { + acc[group.id] = React.createRef(); + return acc; + }, {}); } render() { - return (
-

- Gruppenphase - -

- - {this.props.groups.map(group => ( - - ))} - -
); + return ( +
+

+ Gruppenphase +

+ + {this.props.groups.map(group => ( + + ))} + +
+ ); } } -function ShowMatchesToggleButton(props) { - return (); +function ShowMatchesToggleChevron(props) { + const toggleClass = props.show ? 'rotate' : ''; + return ( + + ); } - export class Group extends Component { constructor(props) { super(props); - this.state = props.group; + this.state = { + ...props.group, + showMatches: false + }; this.reload = this.reload.bind(this); + this.handleToggle = this.handleToggle.bind(this); this.onReloadSuccess = this.onReloadSuccess.bind(this); this.onReloadError = this.onReloadError.bind(this); } + handleToggle() { + this.setState(prevState => ({showMatches: !prevState.showMatches})); + if (this.props.groupRef.current) { + this.props.groupRef.current.scrollIntoView({behavior: 'smooth', block: 'center'}); + } + } + reload() { getGroup(this.state.id, this.onReloadSuccess, this.onReloadError); } @@ -67,31 +85,45 @@ export class Group extends Component { } render() { - return ( - - -

Gruppe {this.state.number}

- - {this.state.matches.sort(sortMatchesByPositionAscending()).map(match => ( - - ))} - - -
-
- ); + const teamIds = new Set(); + this.state.matches.forEach(match => { + teamIds.add(match.team1.id); + teamIds.add(match.team2.id); + }); + const teamIdsString = Array.from(teamIds).join(','); + return ( + + + +

+ Gruppe {this.state.number} +

+ + + {this.state.matches.sort(sortMatchesByPositionAscending()).map(match => ( + + ))} + + +
+
+ + ); } } function GroupScoresTable(props) { return ( - +
@@ -117,9 +149,10 @@ function GroupScoresTable(props) { function GroupScoresTableRow(props) { const advancingTeam = props.teams?.find(team => team.id === props.score.team.id && team.advancing_from_group_stage); const rowClass = advancingTeam ? 'table-success' : ''; + const teamId = `favorite-team-groupstage-${props.score.team.id}`; return ( - + diff --git a/js/components/LinkButton.js b/js/components/LinkButton.js index 619551c..c1fb806 100644 --- a/js/components/LinkButton.js +++ b/js/components/LinkButton.js @@ -3,14 +3,6 @@ import {useRouter} from 'next/router'; import React from 'react'; class LinkButtonComponent extends React.Component { - constructor(props) { - super(props); - this.defaultProps = { - outline: true, - color: 'secondary' - }; - } - handleClick(e) { e.preventDefault(); this.props.router.push(this.props.href); @@ -30,9 +22,7 @@ LinkButtonComponent.defaultProps = { color: 'secondary' }; -// export default withRouter(LinkButton); - export function LinkButton(props) { const router = useRouter(); - return (); + return (); } diff --git a/js/components/Match.js b/js/components/Match.js index 7396122..104957e 100644 --- a/js/components/Match.js +++ b/js/components/Match.js @@ -122,12 +122,27 @@ export class Match extends React.Component { const groupInformation = this.state.match.group ?
Gr. {this.state.match.group.number}
: ''; - + let team1Id; let team2Id; + if (this.props.stageLevel !== undefined) { + team1Id = `${this.props.stageLevel}-${this.props.match.team1.id}`; + team2Id = `${this.props.stageLevel}-${this.props.match.team2.id}`; + } else { + team1Id = undefined; + team2Id = undefined; + } return (
- +
- + {groupInformation} diff --git a/js/components/PlayoffStages.js b/js/components/PlayoffStages.js index 79fe6b6..ddc3f13 100644 --- a/js/components/PlayoffStages.js +++ b/js/components/PlayoffStages.js @@ -50,7 +50,7 @@ export class PlayoffStages extends Component { return (
{this.props.playoffStages.map(stage => this.updateNextStage(stage.id)} - level={getLevelName(stage.level)} matches={stage.matches} + level={getLevelName(stage.level)} matches={stage.matches} stageLevel={stage.level} key={stage.level}/>)}
); } diff --git a/js/components/ScrollToTopButton.js b/js/components/ScrollToTopButton.js new file mode 100644 index 0000000..8e16f7a --- /dev/null +++ b/js/components/ScrollToTopButton.js @@ -0,0 +1,46 @@ +import React, {useState, useEffect} from 'react'; +import {Button} from 'reactstrap'; +import {FaCircleArrowUp} from 'react-icons/fa6'; + +export function ScrollToTopButton() { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const handleScroll = () => { + if (window.scrollY > 1.5 * window.innerHeight) { + setIsVisible(true); + } else { + setIsVisible(false); + } + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const scrollToTop = () => { + window.scrollTo({top: 0, behavior: 'smooth'}); + }; + + return ( + + ); +} diff --git a/js/components/Stage.js b/js/components/Stage.js index a91a328..97f6cfd 100644 --- a/js/components/Stage.js +++ b/js/components/Stage.js @@ -3,15 +3,22 @@ import {Match} from './Match'; import React from 'react'; export function Stage(props) { - const {isSignedIn, isOwner, updateNextStage} = props; + const {isSignedIn, isOwner, updateNextStage, stageLevel} = props; return (

{props.level}

{props.matches.map((match => ( -
)))} + + + )))} ); diff --git a/js/redux/tournamentApi.js b/js/redux/tournamentApi.js index 2bca5cb..e1972a3 100644 --- a/js/redux/tournamentApi.js +++ b/js/redux/tournamentApi.js @@ -41,7 +41,13 @@ export function getTournamentMatches(tournamentId, successCallback, errorCallbac } getRequest(getState(), '/tournaments/' + tournamentId + '/matches' + matchFilter) .then(response => { - successCallback(response.status, response.data.sort((a, b) => a.position > b.position).map(match => convertMatch(match))); + successCallback( + response.status, response.data.sort( + (a, b) => a.position > b.position + ).map( + match => convertMatch(match) + ) + ); }) .catch(errorCallback); } diff --git a/package.json b/package.json index 2c46b03..8ad63aa 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "qrcode.react": "^3.1.0", "react": "^18.1.0", "react-dom": "^18.1.0", + "react-icons": "^5.5.0", "react-notify-toast": "^0.5.1", "react-pose": "^4.0.10", "react-redux": "^8.0.2", diff --git a/pages/create.js b/pages/create.js index bace474..1adb14f 100644 --- a/pages/create.js +++ b/pages/create.js @@ -141,8 +141,8 @@ class CreateTournamentForm extends React.Component { diff --git a/pages/index.js b/pages/index.js index 605a7c0..9b0e3c0 100644 --- a/pages/index.js +++ b/pages/index.js @@ -71,8 +71,8 @@ function MainBottomSummary() {

Ich habe einen Turniercode bekommen. Was nun?

- Der Turniercode führt dich direkt zu einem Turnier. Gebe dafür den Code oben ein, + Der Turniercode führt dich direkt zu einem Turnier. Gebe dafür den Code + oben ein, dann wirst du sofort weitergeleitet.

diff --git a/pages/private.js b/pages/private.js index b019592..4f76721 100644 --- a/pages/private.js +++ b/pages/private.js @@ -9,11 +9,11 @@ import {Footer} from '../js/components/Footer'; import TournamentList from '../js/components/TournamentList'; import RequireLogin from '../js/components/RequireLogin'; -import { LinkButton } from '../js/components/LinkButton'; +import {LinkButton} from '../js/components/LinkButton'; class PrivateTournamentsPage extends React.Component { render() { - return ( + return (
Private Turniere: turnie.re @@ -42,10 +42,10 @@ function PrivateTournamentsPageContent(props) { - zu den öffentlichen Turnieren + zu den öffentlichen Turnieren { props.isSignedIn && - neues Turnier erstellen + neues Turnier erstellen } @@ -57,7 +57,7 @@ class PrivateTournamentsCard extends React.Component { return (

Private Turniere

- +
); } diff --git a/pages/tournament-fullscreen-groups.js b/pages/tournament-fullscreen-groups.js index 4f3d3b0..cd05e26 100644 --- a/pages/tournament-fullscreen-groups.js +++ b/pages/tournament-fullscreen-groups.js @@ -4,7 +4,7 @@ import {ErrorPageComponent} from '../js/components/ErrorComponents'; import {getTournament} from '../js/redux/tournamentApi'; import { Col, - Container, Navbar, NavbarBrand, NavItem, Row, Spinner + Container, Row, Spinner } from 'reactstrap'; import {QRCodeSVG} from 'qrcode.react'; import {Group} from '../js/components/GroupStage'; @@ -15,21 +15,21 @@ function FullscreenPage(props) { if (props.showLogo) { logo =
- +
; } else { - logo =
; + logo =
; } return (
- - - {props.groups.map(group =>
)} + + + {props.groups.map(group => )}
@@ -40,13 +40,6 @@ function FullscreenPage(props) { ); } -function FullscreenPageHeader(props) { - return ( - {props.title} - {props.page}/{props.maxPage} - ); -} - class Main extends React.Component { static async getInitialProps({query}) { @@ -122,9 +115,9 @@ class Main extends React.Component { Vollbild-Ansicht: turnie.re - - - lade Vollbild-Ansicht + + + lade Vollbild-Ansicht ); } diff --git a/pages/tournament-fullscreen.js b/pages/tournament-fullscreen.js index 78b3a5c..cea168b 100644 --- a/pages/tournament-fullscreen.js +++ b/pages/tournament-fullscreen.js @@ -3,7 +3,7 @@ import React from 'react'; import {ErrorPageComponent} from '../js/components/ErrorComponents'; import {getTournamentMatches, getTournamentMeta} from '../js/redux/tournamentApi'; import { - Col, Container, DropdownItem, DropdownMenu, DropdownToggle, Navbar, NavbarBrand, NavItem, Row, UncontrolledDropdown, + Col, Container, DropdownItem, DropdownMenu, DropdownToggle, Navbar, NavbarBrand, Row, UncontrolledDropdown, Spinner } from 'reactstrap'; import {Match} from '../js/components/Match'; diff --git a/pages/tournament.js b/pages/tournament.js index f9461f7..40ca320 100644 --- a/pages/tournament.js +++ b/pages/tournament.js @@ -15,6 +15,8 @@ import {EditButton, TournamentStatusBar} from '../js/components/TournamentStatus import {LinkButton} from '../js/components/LinkButton'; import {LoadingPage} from '../js/components/LoadingPage'; import {getTournament} from '../js/redux/tournamentApi'; +import {FavoriteBar} from '../js/components/FavoriteBar'; +import {ScrollToTopButton} from '../js/components/ScrollToTopButton'; class PrivateTournamentPage extends React.Component { render() { @@ -22,22 +24,26 @@ class PrivateTournamentPage extends React.Component { const {isSignedIn, username} = this.props; const isOwner = username === ownerUsername; - return (
- - -
- {groupStage != null && -
} - + return ( +
+ + + +
+ {groupStage != null && +
} + +
+
-
); + ); } } @@ -47,7 +53,6 @@ function StatusBar(props) { - Vollbild-Ansicht Gruppen ); } diff --git a/public/static/css/tournament.css b/public/static/css/tournament.css index 37d105e..42110f7 100644 --- a/public/static/css/tournament.css +++ b/public/static/css/tournament.css @@ -10,12 +10,16 @@ background-color: #f8f8f8; } +.favorites { + background-color: #f8f8f8; +} + .minw-25 { min-width: 350px; } .match:hover { - box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; + box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15) !important; } .match:hover > div { @@ -27,6 +31,49 @@ width: 11rem; } +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } +} + +.pulse-animation { + animation: pulse 1s infinite; +} + +.scroll-to-highlight { + animation: pulse 1s ease-in-out; +} + +.favorite-bar { + max-height: 0; + overflow: hidden; + transition: max-height 0.7s ease-in-out; +} + +.favorite-bar.visible { + max-height: none; +} + +.my-chevron { + transition: transform 0.3s ease; + color: gray; +} +.my-chevron.rotate { + transform: rotate(180deg); +} +.button-no-focus:focus:not(:focus-visible), +.button-no-focus:active { + outline: none !important; + box-shadow: none !important; +} + .hide-cursor { cursor: none; } diff --git a/yarn.lock b/yarn.lock index 2a8a372..2ae0342 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5828,6 +5828,11 @@ react-fast-compare@^3.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== +react-icons@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.5.0.tgz#8aa25d3543ff84231685d3331164c00299cdfaf2" + integrity sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw== + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
#
{props.score.position} {props.score.team.name} {props.score.group_points}