Merge branch 'favorites'

This commit is contained in:
Daniel Schädler 2025-03-19 15:40:09 +01:00
commit 0e56691fbc
19 changed files with 479 additions and 112 deletions

View File

@ -9,3 +9,9 @@ variables:
include:
- project: 'turniere/turniere-infra'
file: '/ci/pipeline.yaml'
eslint:
stage: test
script:
- yarn install
- yarn eslint .

View File

@ -39,3 +39,7 @@ $ docker build -t turniere-frontend .
```
The built container exposes port 80.
# Todo
Timer in topnav

View File

@ -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 <div>Loading...</div>;
}
const sortedTeams = [...teams].sort((a, b) => a.name.localeCompare(b.name));
const filteredTeams = sortedTeams.filter(team => team.name.toLowerCase().includes(searchQuery.toLowerCase()));
return (
<div className="favorites border-bottom py-2 px-1">
<div className="d-flex align-items-center">
<h1 className="custom-font m-2 px-2" ref={headingRef}>Favorit:</h1>
<p className="m-2">{favorite ? favorite.name : ''}</p>
<ButtonGroup className="m-2">
<Button
title="{isVisible ? 'Favoriten schließen' : 'Favoriten öffnen'}"
onClick={() => setIsVisible(!isVisible)}
>
<FaHeartCirclePlus/>
</Button>
{favorite && (
<Button
title="Zum aktuellen Spiel des Favoriten springen"
onClick={scrollToFavorite}
className={isPulsing ? 'pulse-animation' : ''}
innerRef={scrollButtonRef}
>
<FaArrowTurnDown/>
</Button>
)}
</ButtonGroup>
</div>
<div className={`favorite-bar ${isVisible ? 'visible' : ''}`} ref={favoriteBarRef}>
{sortedTeams.length > 5 && (
<Input
type="text"
placeholder="Team suchen..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="mb-2"
/>
)}
<div>
{filteredTeams.map(team => (
<div
key={team.id}
onClick={() => toggleFavorite(team)}
style={{display: 'flex', alignItems: 'center', cursor: 'pointer'}}
>
<Button
onClick={e => {
e.stopPropagation();
toggleFavorite(team);
}}
style={{marginRight: '10px'}}
>
{favorite && favorite.id === team.id ? <FaHeart/> : <FaRegHeart/>}
</Button>
<span>{team.name}</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -4,48 +4,73 @@ 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 (<div className='py-2 px-1'>
<h1 className='custom-font'>
<span className='px-2'>Gruppenphase</span>
<ShowMatchesToggleButton show={this.state.showMatches} toggle={this.toggleShowMatches}/>
</h1>
<Row className='mt-3 gx-0'>
{this.props.groups.map(group => <Group group={group} key={group.id} isSignedIn={this.props.isSignedIn}
isOwner={this.props.isOwner} showMatches={this.state.showMatches}/>)}
</Row>
</div>);
return (
<div className='py-2 px-1'>
<h1 className='custom-font'>
<span className='px-2'>Gruppenphase</span>
</h1>
<Row className='mt-3 gx-0'>
{this.props.groups.map(group => (
<Group
group={group}
key={group.id}
isSignedIn={this.props.isSignedIn}
isOwner={this.props.isOwner}
groupRef={this.groupRefs[group.id]}
/>
))}
</Row>
</div>
);
}
}
function ShowMatchesToggleButton(props) {
return (<Button onClick={props.toggle} className='float-right default-font-family'>
{props.show ? 'Spiele ausblenden' : 'Spiele anzeigen'}
</Button>);
function ShowMatchesToggleChevron(props) {
const toggleClass = props.show ? 'rotate' : '';
return (
<Button
color="link"
onClick={props.toggle}
className="position-absolute top-0 end-0 m-2 mt-1 button-no-focus"
title={props.show ? 'Matches ausblenden' : 'Matches einblenden'}
>
<FaChevronDown className={`my-chevron ${toggleClass}`}/>
</Button>
);
}
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);
}
@ -59,25 +84,46 @@ export class Group extends Component {
}
render() {
return (<Col className='minw-25 py-2'>
<Card>
<CardBody>
<h3 className='custom-font'>Gruppe {this.state.number}</h3>
<Collapse isOpen={this.props.showMatches}>
{this.state.matches.sort(sortMatchesByPositionAscending()).map((match => (
<Match match={match} isSignedIn={this.props.isSignedIn} isOwner={this.props.isOwner}
onChange={this.reload} key={match.id}/>)))}
</Collapse>
<GroupScoresTable scores={this.state.scores}/>
</CardBody>
</Card>
</Col>);
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 (
<Col className="minw-25 py-2">
<Card ref={this.props.groupRef} data-teams={teamIdsString}>
<CardBody className="position-relative">
<h3 className="custom-font">
Gruppe {this.state.number}
</h3>
<ShowMatchesToggleChevron
show={this.state.showMatches}
toggle={this.handleToggle}
/>
<Collapse isOpen={this.state.showMatches}>
{this.state.matches.sort(sortMatchesByPositionAscending()).map(match => (
<Match
match={match}
isSignedIn={this.props.isSignedIn}
isOwner={this.props.isOwner}
onChange={this.reload}
key={match.id}
/>
))}
</Collapse>
<GroupScoresTable scores={this.state.scores}/>
</CardBody>
</Card>
</Col>
);
}
}
function GroupScoresTable(props) {
return (<Table className='mt-4' striped size='sm' responsive>
<thead>
return (
<Table className="mt-4" striped size="sm" responsive>
<thead>
<tr>
<th>#</th>
<th>Team</th>
@ -85,20 +131,23 @@ function GroupScoresTable(props) {
<th><span title="Becherdifferenz">Dif.</span></th>
<th><span title="Getroffene Becher (Geworfen)">Gew.</span></th>
</tr>
</thead>
<tbody>
</thead>
<tbody>
{props.scores.map(groupScore => <GroupScoresTableRow score={groupScore} key={groupScore.id}/>)}
</tbody>
</Table>);
</tbody>
</Table>
);
}
function GroupScoresTableRow(props) {
return (<tr>
<td>{props.score.position}</td>
<td>{props.score.team.name}</td>
<td>{props.score.group_points}</td>
<td>{props.score.difference_in_points}</td>
<td>{props.score.scored_points}</td>
</tr>);
const teamId = `favorite-team-groupstage-${props.score.team.id}`;
return (
<tr id={teamId}>
<td>{props.score.position}</td>
<td>{props.score.team.name}</td>
<td>{props.score.group_points}</td>
<td>{props.score.difference_in_points}</td>
<td>{props.score.scored_points}</td>
</tr>
);
}

View File

@ -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 (<LinkButtonComponent {...props} router={router} />);
return (<LinkButtonComponent {...props} router={router}/>);
}

View File

@ -122,12 +122,27 @@ export class Match extends React.Component {
const groupInformation = this.state.match.group ?
<div className="mb-2 mt-2">Gr. {this.state.match.group.number}</div> :
'';
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 (<div className='mb-3'>
<Card className={'shadow-sm match '} onClick={this.toggleModal}>
<Card
className={'shadow-sm match'}
onClick={this.toggleModal}
data-team-level-ids={`${team1Id},${team2Id}`}
>
<div className="d-flex flex-row">
<CardBody className={borderClass + ' border py-2 ' + cardClass + ' ' + styles.match_bg}>
<MatchTable match={this.state.match} borderColor={borderClass}/>
<MatchTable
match={this.state.match}
stageLevel={this.props.stageLevel}
borderColor={borderClass}
/>
</CardBody>
<span className="badge bg-secondary align-items-center">
{groupInformation}

View File

@ -50,7 +50,7 @@ export class PlayoffStages extends Component {
return (<div>
{this.props.playoffStages.map(stage => <Stage isSignedIn={this.props.isSignedIn}
isOwner={this.props.isOwner} updateNextStage={() => this.updateNextStage(stage.id)}
level={getLevelName(stage.level)} matches={stage.matches}
level={getLevelName(stage.level)} matches={stage.matches} stageLevel={stage.level}
key={stage.level}/>)}
</div>);
}

View File

@ -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 (
<Button
onClick={scrollToTop}
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
borderRadius: '50%',
width: '50px',
height: '50px',
zIndex: 999,
transition: 'opacity 0.5s ease-in-out',
opacity: isVisible ? 0.7 : 0,
pointerEvents: isVisible ? 'auto' : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<FaCircleArrowUp style={{fontSize: '36px'}}/>
</Button>
);
}

View File

@ -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 (<div>
<Container className='py-5'>
<h1 className='custom-font'>{props.level}</h1>
<Row>
{props.matches.map((match => (
<Col className='minw-25' key={match.id}><Match match={match} isSignedIn={isSignedIn}
isOwner={isOwner} onFinish={updateNextStage}/></Col>)))}
<Col className='minw-25' key={match.id}>
<Match
match={match}
isSignedIn={isSignedIn}
isOwner={isOwner}
onFinish={updateNextStage}
stageLevel={stageLevel}
/>
</Col>)))}
</Row>
</Container>
</div>);

View File

@ -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);
}
@ -67,7 +73,8 @@ function convertTournament(apiTournament) {
isPublic: apiTournament.public,
ownerUsername: apiTournament.owner_username,
groupStage: groupStage,
playoffStages: playoffStages
playoffStages: playoffStages,
teams: apiTournament.teams
};
}

View File

@ -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",

View File

@ -141,8 +141,8 @@ class CreateTournamentForm extends React.Component {
</Col>
</Row>
<WarningPopup
text={'Füge bitte noch ' + (this.state.groupAdvance - this.state.teams.length)
+ ' Team(s) hinzu, um ' + this.state.groupAdvance + ' Team(s) im Playoff zu haben.'}
text={'Füge bitte noch ' + (this.state.groupAdvance - this.state.teams.length) +
' Team(s) hinzu, um ' + this.state.groupAdvance + ' Team(s) im Playoff zu haben.'}
shown={this.state.teams.length < this.state.groupAdvance}/>
</FormGroup>
</Collapse>

View File

@ -71,8 +71,8 @@ function MainBottomSummary() {
<div className="col-lg-6">
<h2>Ich habe einen Turniercode bekommen. Was nun?</h2>
<p>
Der Turniercode führt dich direkt zu einem Turnier. Gebe dafür den Code <a className="text-success"
href="#turniercode-form">oben</a> ein,
Der Turniercode führt dich direkt zu einem Turnier. Gebe dafür den Code
<a className="text-success" href="#turniercode-form">oben</a> ein,
dann wirst du sofort weitergeleitet. </p>
</div>
</div>

View File

@ -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 (<RequireLogin loginMessage='Sie müssen angemeldet sein, um Ihre privaten Turniere zu sehen.'>
return (<RequireLogin loginMessage="Sie müssen angemeldet sein, um Ihre privaten Turniere zu sehen.">
<div className="main generic-fullpage-bg">
<Head>
<title>Private Turniere: turnie.re</title>
@ -42,10 +42,10 @@ function PrivateTournamentsPageContent(props) {
</Container>
<Container className="pb-5 pt-3">
<ButtonGroup>
<LinkButton href="/list" outline={false} color='primary'>zu den öffentlichen Turnieren</LinkButton>
<LinkButton href="/list" outline={false} color="primary">zu den öffentlichen Turnieren</LinkButton>
{
props.isSignedIn &&
<LinkButton href="/create" outline={false} color='success'>neues Turnier erstellen</LinkButton>
<LinkButton href="/create" outline={false} color="success">neues Turnier erstellen</LinkButton>
}
</ButtonGroup>
</Container>
@ -57,7 +57,7 @@ class PrivateTournamentsCard extends React.Component {
return (<Card className="shadow">
<CardBody>
<h1 className="custom-font">Private Turniere</h1>
<TournamentList type='private'/>
<TournamentList type="private"/>
</CardBody>
</Card>);
}

View File

@ -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';
@ -14,22 +14,22 @@ function FullscreenPage(props) {
let logo;
if (props.showLogo) {
logo = <Col>
<div className="d-flex justify-content-center align-items-center">
<img height='300' width='300' src='/static/images/bpwstr_logo.png'></img>
</div>
</Col>;
<div className="d-flex justify-content-center align-items-center">
<img height="300" width="300" src="/static/images/bpwstr_logo.png"></img>
</div>
</Col>;
} else {
logo = <div />;
logo = <div/>;
}
return (<div>
<Container className='fs-5' fluid>
<Row className='row-cols-4'>
{props.groups.map(group => <Col className='mb-2'><Group group={group} key={group.id}/></Col>)}
<Container className="fs-5" fluid>
<Row className="row-cols-4">
{props.groups.map(group => <Col className="mb-2"><Group group={group} key={group.id}/></Col>)}
<Col>
<div className="d-flex justify-content-center align-items-center">
<QRCodeSVG
className='shadow mx-auto'
value='https://qr.bpwstr.de/2'
className="shadow mx-auto"
value="https://qr.bpwstr.de/2"
size="300"
/>
</div>
@ -40,13 +40,6 @@ function FullscreenPage(props) {
</div>);
}
function FullscreenPageHeader(props) {
return (<Navbar color='light' className='mb-4 border-bottom py-0'>
<NavbarBrand>{props.title}</NavbarBrand>
{props.page}/{props.maxPage}
</Navbar>);
}
class Main extends React.Component {
static async getInitialProps({query}) {
@ -122,9 +115,9 @@ class Main extends React.Component {
<Head>
<title>Vollbild-Ansicht: turnie.re</title>
</Head>
<Container className='p-5 text-center text-secondary'>
<Spinner size='sm'/>
<span className='ml-3'>lade Vollbild-Ansicht</span>
<Container className="p-5 text-center text-secondary">
<Spinner size="sm"/>
<span className="ml-3">lade Vollbild-Ansicht</span>
</Container>
</div>);
}

View File

@ -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';

View File

@ -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,17 +24,21 @@ class PrivateTournamentPage extends React.Component {
const {isSignedIn, username} = this.props;
const isOwner = username === ownerUsername;
return (<div className='pb-5'>
<TournamentBigImage {...this.props.tournament}/>
<StatusBar tournament={this.props.tournament} isOwner={isOwner} isSignedIn={isSignedIn}/>
<div className='stages'>
{groupStage != null &&
<div><GroupStage groups={groupStage.groups} isSignedIn={isSignedIn} isOwner={isOwner}
showMatches={playoffStages !== null}/></div>}
<PlayoffStages playoffStages={playoffStages} isSignedIn={isSignedIn}
isOwner={isOwner}/>
return (
<div className='pb-5'>
<TournamentBigImage {...this.props.tournament}/>
<StatusBar tournament={this.props.tournament} isOwner={isOwner} isSignedIn={isSignedIn}/>
<FavoriteBar teams={this.props.tournament.teams}/>
<div className='stages'>
{groupStage != null &&
<div><GroupStage groups={groupStage.groups} isSignedIn={isSignedIn} isOwner={isOwner}
showMatches={playoffStages !== null}/></div>}
<PlayoffStages playoffStages={playoffStages} isSignedIn={isSignedIn}
isOwner={isOwner}/>
</div>
<ScrollToTopButton />
</div>
</div>);
);
}
}
@ -42,7 +48,6 @@ function StatusBar(props) {
<EditButton tournamentId={props.tournament.id} isOwner={props.isOwner} isSignedIn={props.isSignedIn}/>
<StatisticsButton tournamentId={props.tournament.id}/>
<FullscreenButton tournamentId={props.tournament.id}/>
<LinkButton href={'/t/' + props.tournament.id + '/fullscreen-groups'}>Vollbild-Ansicht Gruppen</LinkButton>
</ButtonGroup>
</TournamentStatusBar>);
}

View File

@ -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;
}

View File

@ -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"