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 : ''}
+
+ setIsVisible(!isVisible)}
+ >
+
+
+ {favorite && (
+
+
+
+ )}
+
+
+
+ {sortedTeams.length > 5 && (
+
setSearchQuery(e.target.value)}
+ className="mb-2"
+ />
+ )}
+
+ {filteredTeams.map(team => (
+
toggleFavorite(team)}
+ style={{display: 'flex', alignItems: 'center', cursor: 'pointer'}}
+ >
+ {
+ e.stopPropagation();
+ toggleFavorite(team);
+ }}
+ style={{marginRight: '10px'}}
+ >
+ {favorite && favorite.id === team.id ? : }
+
+ {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 (
- {props.show ? 'Spiele ausblenden' : 'Spiele anzeigen'}
- );
+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 (
-
+
{props.score.position}
{props.score.team.name}
{props.score.group_points}
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"