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: include:
- project: 'turniere/turniere-infra' - project: 'turniere/turniere-infra'
file: '/ci/pipeline.yaml' 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. 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 {getGroup} from '../redux/tournamentApi';
import {notify} from 'react-notify-toast'; import {notify} from 'react-notify-toast';
import {sortMatchesByPositionAscending} from '../utils/sorting'; import {sortMatchesByPositionAscending} from '../utils/sorting';
import {FaChevronDown} from 'react-icons/fa6';
export default class GroupStage extends Component { export default class GroupStage extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = {showMatches: this.props.showMatches}; this.groupRefs = this.props.groups.reduce((acc, group) => {
this.toggleShowMatches = this.toggleShowMatches.bind(this); acc[group.id] = React.createRef();
} return acc;
}, {});
toggleShowMatches() {
this.setState({showMatches: !this.state.showMatches});
} }
render() { render() {
return (<div className='py-2 px-1'> return (
<h1 className='custom-font'> <div className='py-2 px-1'>
<span className='px-2'>Gruppenphase</span> <h1 className='custom-font'>
<ShowMatchesToggleButton show={this.state.showMatches} toggle={this.toggleShowMatches}/> <span className='px-2'>Gruppenphase</span>
</h1> </h1>
<Row className='mt-3 gx-0'> <Row className='mt-3 gx-0'>
{this.props.groups.map(group => <Group group={group} key={group.id} isSignedIn={this.props.isSignedIn} {this.props.groups.map(group => (
isOwner={this.props.isOwner} showMatches={this.state.showMatches}/>)} <Group
</Row> group={group}
</div>); key={group.id}
isSignedIn={this.props.isSignedIn}
isOwner={this.props.isOwner}
groupRef={this.groupRefs[group.id]}
/>
))}
</Row>
</div>
);
} }
} }
function ShowMatchesToggleButton(props) { function ShowMatchesToggleChevron(props) {
return (<Button onClick={props.toggle} className='float-right default-font-family'> const toggleClass = props.show ? 'rotate' : '';
{props.show ? 'Spiele ausblenden' : 'Spiele anzeigen'} return (
</Button>); <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 { export class Group extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = props.group; this.state = {
...props.group,
showMatches: false
};
this.reload = this.reload.bind(this); this.reload = this.reload.bind(this);
this.handleToggle = this.handleToggle.bind(this);
this.onReloadSuccess = this.onReloadSuccess.bind(this); this.onReloadSuccess = this.onReloadSuccess.bind(this);
this.onReloadError = this.onReloadError.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() { reload() {
getGroup(this.state.id, this.onReloadSuccess, this.onReloadError); getGroup(this.state.id, this.onReloadSuccess, this.onReloadError);
} }
@ -59,25 +84,46 @@ export class Group extends Component {
} }
render() { render() {
return (<Col className='minw-25 py-2'> const teamIds = new Set();
<Card> this.state.matches.forEach(match => {
<CardBody> teamIds.add(match.team1.id);
<h3 className='custom-font'>Gruppe {this.state.number}</h3> teamIds.add(match.team2.id);
<Collapse isOpen={this.props.showMatches}> });
{this.state.matches.sort(sortMatchesByPositionAscending()).map((match => ( const teamIdsString = Array.from(teamIds).join(',');
<Match match={match} isSignedIn={this.props.isSignedIn} isOwner={this.props.isOwner} return (
onChange={this.reload} key={match.id}/>)))} <Col className="minw-25 py-2">
</Collapse> <Card ref={this.props.groupRef} data-teams={teamIdsString}>
<GroupScoresTable scores={this.state.scores}/> <CardBody className="position-relative">
</CardBody> <h3 className="custom-font">
</Card> Gruppe {this.state.number}
</Col>); </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) { function GroupScoresTable(props) {
return (<Table className='mt-4' striped size='sm' responsive> return (
<thead> <Table className="mt-4" striped size="sm" responsive>
<thead>
<tr> <tr>
<th>#</th> <th>#</th>
<th>Team</th> <th>Team</th>
@ -85,20 +131,23 @@ function GroupScoresTable(props) {
<th><span title="Becherdifferenz">Dif.</span></th> <th><span title="Becherdifferenz">Dif.</span></th>
<th><span title="Getroffene Becher (Geworfen)">Gew.</span></th> <th><span title="Getroffene Becher (Geworfen)">Gew.</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{props.scores.map(groupScore => <GroupScoresTableRow score={groupScore} key={groupScore.id}/>)} {props.scores.map(groupScore => <GroupScoresTableRow score={groupScore} key={groupScore.id}/>)}
</tbody> </tbody>
</Table>); </Table>
);
} }
function GroupScoresTableRow(props) { function GroupScoresTableRow(props) {
return (<tr> const teamId = `favorite-team-groupstage-${props.score.team.id}`;
<td>{props.score.position}</td> return (
<td>{props.score.team.name}</td> <tr id={teamId}>
<td>{props.score.group_points}</td> <td>{props.score.position}</td>
<td>{props.score.difference_in_points}</td> <td>{props.score.team.name}</td>
<td>{props.score.scored_points}</td> <td>{props.score.group_points}</td>
</tr>); <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'; import React from 'react';
class LinkButtonComponent extends React.Component { class LinkButtonComponent extends React.Component {
constructor(props) {
super(props);
this.defaultProps = {
outline: true,
color: 'secondary'
};
}
handleClick(e) { handleClick(e) {
e.preventDefault(); e.preventDefault();
this.props.router.push(this.props.href); this.props.router.push(this.props.href);
@ -30,9 +22,7 @@ LinkButtonComponent.defaultProps = {
color: 'secondary' color: 'secondary'
}; };
// export default withRouter(LinkButton);
export function LinkButton(props) { export function LinkButton(props) {
const router = useRouter(); 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 ? const groupInformation = this.state.match.group ?
<div className="mb-2 mt-2">Gr. {this.state.match.group.number}</div> : <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'> 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"> <div className="d-flex flex-row">
<CardBody className={borderClass + ' border py-2 ' + cardClass + ' ' + styles.match_bg}> <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> </CardBody>
<span className="badge bg-secondary align-items-center"> <span className="badge bg-secondary align-items-center">
{groupInformation} {groupInformation}

View File

@ -50,7 +50,7 @@ export class PlayoffStages extends Component {
return (<div> return (<div>
{this.props.playoffStages.map(stage => <Stage isSignedIn={this.props.isSignedIn} {this.props.playoffStages.map(stage => <Stage isSignedIn={this.props.isSignedIn}
isOwner={this.props.isOwner} updateNextStage={() => this.updateNextStage(stage.id)} 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}/>)} key={stage.level}/>)}
</div>); </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'; import React from 'react';
export function Stage(props) { export function Stage(props) {
const {isSignedIn, isOwner, updateNextStage} = props; const {isSignedIn, isOwner, updateNextStage, stageLevel} = props;
return (<div> return (<div>
<Container className='py-5'> <Container className='py-5'>
<h1 className='custom-font'>{props.level}</h1> <h1 className='custom-font'>{props.level}</h1>
<Row> <Row>
{props.matches.map((match => ( {props.matches.map((match => (
<Col className='minw-25' key={match.id}><Match match={match} isSignedIn={isSignedIn} <Col className='minw-25' key={match.id}>
isOwner={isOwner} onFinish={updateNextStage}/></Col>)))} <Match
match={match}
isSignedIn={isSignedIn}
isOwner={isOwner}
onFinish={updateNextStage}
stageLevel={stageLevel}
/>
</Col>)))}
</Row> </Row>
</Container> </Container>
</div>); </div>);

View File

@ -41,7 +41,13 @@ export function getTournamentMatches(tournamentId, successCallback, errorCallbac
} }
getRequest(getState(), '/tournaments/' + tournamentId + '/matches' + matchFilter) getRequest(getState(), '/tournaments/' + tournamentId + '/matches' + matchFilter)
.then(response => { .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); .catch(errorCallback);
} }
@ -67,7 +73,8 @@ function convertTournament(apiTournament) {
isPublic: apiTournament.public, isPublic: apiTournament.public,
ownerUsername: apiTournament.owner_username, ownerUsername: apiTournament.owner_username,
groupStage: groupStage, groupStage: groupStage,
playoffStages: playoffStages playoffStages: playoffStages,
teams: apiTournament.teams
}; };
} }

View File

@ -22,6 +22,7 @@
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.1.0", "react": "^18.1.0",
"react-dom": "^18.1.0", "react-dom": "^18.1.0",
"react-icons": "^5.5.0",
"react-notify-toast": "^0.5.1", "react-notify-toast": "^0.5.1",
"react-pose": "^4.0.10", "react-pose": "^4.0.10",
"react-redux": "^8.0.2", "react-redux": "^8.0.2",

View File

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

View File

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

View File

@ -9,11 +9,11 @@ import {Footer} from '../js/components/Footer';
import TournamentList from '../js/components/TournamentList'; import TournamentList from '../js/components/TournamentList';
import RequireLogin from '../js/components/RequireLogin'; import RequireLogin from '../js/components/RequireLogin';
import { LinkButton } from '../js/components/LinkButton'; import {LinkButton} from '../js/components/LinkButton';
class PrivateTournamentsPage extends React.Component { class PrivateTournamentsPage extends React.Component {
render() { 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"> <div className="main generic-fullpage-bg">
<Head> <Head>
<title>Private Turniere: turnie.re</title> <title>Private Turniere: turnie.re</title>
@ -42,10 +42,10 @@ function PrivateTournamentsPageContent(props) {
</Container> </Container>
<Container className="pb-5 pt-3"> <Container className="pb-5 pt-3">
<ButtonGroup> <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 && 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> </ButtonGroup>
</Container> </Container>
@ -57,7 +57,7 @@ class PrivateTournamentsCard extends React.Component {
return (<Card className="shadow"> return (<Card className="shadow">
<CardBody> <CardBody>
<h1 className="custom-font">Private Turniere</h1> <h1 className="custom-font">Private Turniere</h1>
<TournamentList type='private'/> <TournamentList type="private"/>
</CardBody> </CardBody>
</Card>); </Card>);
} }

View File

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

View File

@ -3,7 +3,7 @@ import React from 'react';
import {ErrorPageComponent} from '../js/components/ErrorComponents'; import {ErrorPageComponent} from '../js/components/ErrorComponents';
import {getTournamentMatches, getTournamentMeta} from '../js/redux/tournamentApi'; import {getTournamentMatches, getTournamentMeta} from '../js/redux/tournamentApi';
import { import {
Col, Container, DropdownItem, DropdownMenu, DropdownToggle, Navbar, NavbarBrand, NavItem, Row, UncontrolledDropdown, Col, Container, DropdownItem, DropdownMenu, DropdownToggle, Navbar, NavbarBrand, Row, UncontrolledDropdown,
Spinner Spinner
} from 'reactstrap'; } from 'reactstrap';
import {Match} from '../js/components/Match'; 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 {LinkButton} from '../js/components/LinkButton';
import {LoadingPage} from '../js/components/LoadingPage'; import {LoadingPage} from '../js/components/LoadingPage';
import {getTournament} from '../js/redux/tournamentApi'; import {getTournament} from '../js/redux/tournamentApi';
import {FavoriteBar} from '../js/components/FavoriteBar';
import {ScrollToTopButton} from '../js/components/ScrollToTopButton';
class PrivateTournamentPage extends React.Component { class PrivateTournamentPage extends React.Component {
render() { render() {
@ -22,17 +24,21 @@ class PrivateTournamentPage extends React.Component {
const {isSignedIn, username} = this.props; const {isSignedIn, username} = this.props;
const isOwner = username === ownerUsername; const isOwner = username === ownerUsername;
return (<div className='pb-5'> return (
<TournamentBigImage {...this.props.tournament}/> <div className='pb-5'>
<StatusBar tournament={this.props.tournament} isOwner={isOwner} isSignedIn={isSignedIn}/> <TournamentBigImage {...this.props.tournament}/>
<div className='stages'> <StatusBar tournament={this.props.tournament} isOwner={isOwner} isSignedIn={isSignedIn}/>
{groupStage != null && <FavoriteBar teams={this.props.tournament.teams}/>
<div><GroupStage groups={groupStage.groups} isSignedIn={isSignedIn} isOwner={isOwner} <div className='stages'>
showMatches={playoffStages !== null}/></div>} {groupStage != null &&
<PlayoffStages playoffStages={playoffStages} isSignedIn={isSignedIn} <div><GroupStage groups={groupStage.groups} isSignedIn={isSignedIn} isOwner={isOwner}
isOwner={isOwner}/> showMatches={playoffStages !== null}/></div>}
<PlayoffStages playoffStages={playoffStages} isSignedIn={isSignedIn}
isOwner={isOwner}/>
</div>
<ScrollToTopButton />
</div> </div>
</div>); );
} }
} }
@ -42,7 +48,6 @@ function StatusBar(props) {
<EditButton tournamentId={props.tournament.id} isOwner={props.isOwner} isSignedIn={props.isSignedIn}/> <EditButton tournamentId={props.tournament.id} isOwner={props.isOwner} isSignedIn={props.isSignedIn}/>
<StatisticsButton tournamentId={props.tournament.id}/> <StatisticsButton tournamentId={props.tournament.id}/>
<FullscreenButton tournamentId={props.tournament.id}/> <FullscreenButton tournamentId={props.tournament.id}/>
<LinkButton href={'/t/' + props.tournament.id + '/fullscreen-groups'}>Vollbild-Ansicht Gruppen</LinkButton>
</ButtonGroup> </ButtonGroup>
</TournamentStatusBar>); </TournamentStatusBar>);
} }

View File

@ -10,12 +10,16 @@
background-color: #f8f8f8; background-color: #f8f8f8;
} }
.favorites {
background-color: #f8f8f8;
}
.minw-25 { .minw-25 {
min-width: 350px; min-width: 350px;
} }
.match:hover { .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 { .match:hover > div {
@ -27,6 +31,49 @@
width: 11rem; 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 { .hide-cursor {
cursor: none; 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" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== 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: react-is@^16.13.1:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"