import { bind } from "decko";
import linkState from "linkstate";
import { Component, JSX, createRef, h } from "preact";
import { Edit } from "preact-feather/dist/icons/edit";
import { Text } from "preact-jsx-i18n";
import { Radio, RadioGroup } from "preact-radio-group";
import { SteppedLineTo } from "react-lineto";
import { bracketEdit, bracketGet, bracketMatchEdit, bracketYourGet } from "../api";
import Data, { LoadState } from "../Data";
import Helmet from "../Helmet";
import Loading from "../Loading";
import { CancelButton, Modal, ModalContainer } from "../modals";
import { addData } from "../preload";
import { BracketID } from "../types";
declare const SOCKET_HOST: string;
interface PlayerInfo {
id: number;
name: string;
}
type PlayerOutcome = "win" | "loss";
export interface MatchInfo {
id: number;
players: Array<{
player: null | {id: number};
score: null | number;
source: null | {match: number; outcome: PlayerOutcome};
}>;
winner: number | null;
column: number;
section: number;
skipped: boolean;
}
export interface BracketInfo {
id: BracketID;
name: string;
description_md: string;
description_html: string;
players: PlayerInfo[];
matches: MatchInfo[];
use_scores: boolean;
}
interface BracketYourInfo {
isAdmin: boolean;
}
interface PageProps {
id: string;
}
interface PageState {
bracket: LoadState<BracketInfo>;
yourInfo: LoadState<BracketYourInfo>;
wasOpen: boolean;
closed: boolean;
backoff: number;
}
export default class BracketPage extends Component<PageProps, PageState> {
private modals = createRef();
private socket: WebSocket | undefined;
private addModal = (newModal: JSX.Element): void => this.modals.current.add(newModal);
public constructor() {
super();
this.state = {
bracket: Data.loading,
yourInfo: Data.loading,
wasOpen: false,
closed: false,
backoff: 500,
};
}
render(props: PageProps, state: PageState) {
return <div>
<Helmet title="Bracket" />
{state.closed && !(state.yourInfo.state === "failed") && <div class="warningBox"><Text id={state.wasOpen ? "pages_bracket_lostConnection" : "pages_bracket_failedToConnect"} /> <Text id="reconnecting" /></div>}
<Data state={Data.and(state.yourInfo, state.bracket)}>
{([yourInfo, bracket]) => {
return <BracketView bracket={bracket} yourInfo={yourInfo} addModal={this.addModal} />;
}}
</Data>
<ModalContainer ref={this.modals} />
</div>;
}
componentDidMount() {
this.initSocket();
}
@bind
private initSocket(): void {
this.socket = new WebSocket(SOCKET_HOST);
this.socket.addEventListener("message", msg => {
const packet = JSON.parse(msg.data);
console.log(packet);
if(packet.type === 2) {
this.setState({bracket: Data.wrapValue(packet.data.bracket)});
}
if(packet.type === 3) {
const data: {match: Partial<MatchInfo> & {id: number}} = packet.data;
const newMatch = packet.data.match;
this.setState(state => {
const bracket = Data.unwrap(state.bracket);
return {
bracket: Data.wrapValue({
...bracket,
matches: bracket.matches.map(match => {
if(match.id === newMatch.id) return {...match, ...newMatch};
return match;
}),
}),
};
});
}
if(packet.type === 4) {
this.setState(state => {
const bracket = Data.unwrap(state.bracket);
return {
bracket: Data.wrapValue({
...bracket,
...packet.data.info,
}),
};
});
}
});
this.socket.addEventListener("open", () => {
this.socket!.send(JSON.stringify({type: 1, data: {bracketID: this.props.id}}));
this.setState((state: PageState) => ({backoff: state.backoff / 2, closed: false}));
});
this.socket.addEventListener("close", () => {
console.warn("Lost connection to socket");
const backoff = this.state.backoff;
this.setState({closed: true, backoff: backoff * 2}, () => {
setTimeout(this.initSocket, backoff);
});
});
}
}
addData<PageProps, PageState, BracketPage>(
BracketPage,
(props, fetch) => ({
yourInfo: bracketYourGet(props.id, {fetch}),
// preload bracket only on server, since we fetch it from the socket anyway
bracket: (
typeof window !== "undefined" ?
undefined :
Promise.all([
bracketGet(props.id, {fetch}),
import("../markdown"),
])
.then(([info, mdMod]) => {
return {
...info,
description_html: mdMod.default.render(info.description_md),
};
})
),
}),
);
interface PlayerMap {
[key: string]: PlayerInfo;
}
interface BracketViewProps {
bracket: BracketInfo;
yourInfo: BracketYourInfo;
addModal(newModal: JSX.Element): void;
}
interface BracketViewState {
playerMap: PlayerMap;
}
class BracketView extends Component<BracketViewProps, BracketViewState> {
public constructor(props: BracketViewProps) {
super(props);
this.state = {playerMap: makePlayerMap(props.bracket.players)};
}
render(props: BracketViewProps, state: BracketViewState) {
const { bracket } = props;
const columns: number[] = [];
const sections: number[] = [];
bracket.matches.forEach(match => {
const { column, section } = match;
if(columns.indexOf(column) < 0) columns.push(column);
if(sections.indexOf(section) < 0) sections.push(section);
});
columns.sort();
sections.sort();
return <div>
<Helmet
title={bracket.name}
meta={[{httpEquiv: "refresh", content: "5", noScriptOnly: true}]}
/>
<div>
<div class="bracketHeader">
<div>
<h1>
{bracket.name}
{props.yourInfo && props.yourInfo.isAdmin && <a class="button" href={"/dialog/bracketInfoEdit/" + bracket.id} onClick={this.editBracketInfoClick}><Edit size={16} /></a>}
</h1>
<p dangerouslySetInnerHTML={{__html: bracket.description_html}}></p>
<LeaderboardView bracket={bracket} />
</div>
<div class="qrArea">
<img src={"/api/unstable/brackets/" + bracket.id + "/linkQr"} />
</div>
</div>
<div class={"bracket bracketMagic_" + bracket.id}>
{sections.map(section => {
return <div class="section" key={section}>
{columns.map(column => {
return <div class="column" key={column}>
{bracket.matches.filter(b => b.column === column && b.section === section && !b.skipped).map(match => {
const showButtons = props.yourInfo && props.yourInfo.isAdmin;
return <div class={"matchMagic_" + match.id} key={match.id}>
<div class={showButtons ? "withButtons" : "noButtons"}>
<ul class="players">
{match.players.map((matchPlayer, idx) => {
return <li key={match.id + "_player_" + idx} class={"matchMagic_" + match.id + "_player_" + idx + (idx === match.winner ? " winner" : "")}>
{(!bracket.use_scores || matchPlayer.score === null) ? null : <div>({matchPlayer.score})</div>}
<div class="name">
{matchPlayer.player !== null && state.playerMap[matchPlayer.player.id].name}
</div>
</li>;
})}
</ul>
{showButtons && <div class="buttons">
<a href={"/dialog/matchEdit/" + bracket.id + "/" + match.id} class="button" onClick={this.editClick.bind(this, match)}><Edit size={16} /></a>
</div>}
</div>
</div>;
})}
</div>;
})}
</div>
})}
{typeof window !== "undefined" && bracket.matches.map(match => {
return match.players.map((matchPlayer, idx) => {
if(matchPlayer.source && matchPlayer.source.outcome === "win") {
return <SteppedLineTo from={"matchMagic_" + matchPlayer.source.match} to={"matchMagic_" + match.id + "_player_" + idx} orientation="h" delay fromAnchor="right" toAnchor="left" borderColor="gray" within={"bracketMagic_" + bracket.id} />;
}
});
})}
</div>
</div>
</div>;
}
@bind
editBracketInfoClick(evt: Event): void {
evt.preventDefault();
evt.stopPropagation();
this.props.addModal(<BracketInfoEditDialog bracket={this.props.bracket} />);
}
@bind
editClick(match: MatchInfo, evt: Event): void {
evt.preventDefault();
evt.stopPropagation();
this.props.addModal(<MatchEditDialog match={match} playerMap={this.state.playerMap} bracket={this.props.bracket} />);
}
}
function LeaderboardView(props: {bracket: BracketInfo}): JSX.Element {
const { bracket } = props;
const scores: {[key: string]: number | undefined} = {};
bracket.matches.forEach(match => {
if(match.winner !== null) {
const winner = match.players[match.winner];
if(winner.player) {
scores[winner.player.id] = (scores[winner.player.id] || 0) + 1;
}
}
});
const ordered: PlayerInfo[] = bracket.players.slice();
ordered.sort((a, b) => (scores[b.id] || 0) - (scores[a.id] || 0));
return <div>
<ol>
{ordered.map(player => {
return <li key={player.id}>
({scores[player.id] || 0}) {player.name}
</li>;
})}
</ol>
</div>;
}
interface MatchEditProps {
bracket: Pick<BracketInfo, "id" | "use_scores">;
match: MatchInfo;
playerMap: PlayerMap;
}
interface MatchEditState {
scores: number[];
winner: number | null;
submitting: boolean;
}
export class MatchEditDialog extends Component<MatchEditProps, MatchEditState> {
private modal = createRef();
public constructor(props: MatchEditProps) {
super(props);
this.state = {
scores: props.match.players.map(player => player.score || 0),
winner: props.match.winner,
submitting: false,
};
}
public render(props: MatchEditProps, state: MatchEditState) {
return <Modal title={<Text id="editMatch" />} ref={this.modal}>
<form onSubmit={this.submit.bind(this)} method="POST" action="/formSubmit/matchEdit">
<div class="modal-body">
<input type="hidden" name="bracket" value={props.bracket.id} />
<input type="hidden" name="match" value={props.match.id} />
<RadioGroup name="winner" selectedValue={String(state.winner)} onChange={this.onChangeWinner.bind(this)}>
<table>
<tr>
<th><Text id="player" /></th>
{props.bracket.use_scores && <th><Text id="score" /></th>}
<th><Text id="whetherWinner" /></th>
</tr>
{props.match.players.map((player, idx) => {
return <tr key={idx}>
<td>
{player.player === null ? ["[", <Text id="unknown" />, "]"] : props.playerMap[player.player.id].name}
</td>
{props.bracket.use_scores && <td>
<input type="number" value={state.scores[idx]} onChange={this.onChangeScore.bind(this, idx)} name="score[]" />
</td>}
<td>
<Radio value={String(idx)} />
</td>
</tr>;
})}
<tr>
<td />
{props.bracket.use_scores && <td />}
<td>
<Radio value="null" />
</td>
</tr>
</table>
</RadioGroup>
</div>
<div class="modal-footer modal-footer-buttons">
<CancelButton />
<button type="submit" class="primary" disabled={state.submitting}>
<Text id={state.submitting ? "saving" : "save"} />
</button>
</div>
</form>
</Modal>;
}
public componentDidMount() {
(this.base as HTMLElement).getElementsByTagName("input")[0].focus();
}
private onChangeScore(index: number, evt: JSX.TargetedEvent<HTMLInputElement>): void {
this.setState(state => {
const newValue = state.scores.slice();
newValue[index] = parseInt(evt.currentTarget.value, 10);
return {scores: newValue};
});
}
private onChangeWinner(newValue: string): void {
this.setState({winner: newValue === "null" ? null : parseInt(newValue, 10)});
}
private submit(evt: JSX.TargetedEvent<HTMLFormElement>): void {
evt.preventDefault();
this.setState({submitting: true}, () => {
bracketMatchEdit(
this.props.bracket.id,
this.props.match.id,
{scores: this.props.bracket.use_scores ? this.state.scores : undefined, winner: this.state.winner},
)
.then(() => {
this.modal.current.dismiss();
})
.catch(console.error)
.then(() => {
this.setState({submitting: false});
});
});
}
}
interface BracketInfoEditProps {
bracket: Pick<BracketInfo, "name" | "description_md" | "id">;
}
interface BracketInfoEditState {
name: string | undefined;
description: string | undefined;
submitting: boolean;
}
export class BracketInfoEditDialog extends Component<BracketInfoEditProps, BracketInfoEditState> {
private modal = createRef();
public render(props: BracketInfoEditProps, state: BracketInfoEditState) {
const { bracket } = props;
return <Modal title={<Text id="editBracketInfo" />} ref={this.modal}>
<form method="POST" action="/formSubmit/bracketInfoEdit" onSubmit={this.submit}>
<div class="modal-body">
<input type="hidden" name="bracket" value={bracket.id} />
<label class="form-control-label">
<Text id="name" />
<input type="text" value={typeof state.name === "undefined" ? bracket.name : state.name} onChange={linkState(this, "name")} name="bracketName" />
</label>
<label class="form-control-label">
<Text id="description" />
<textarea value={typeof state.description === "undefined" ? bracket.description_md : state.description} onChange={linkState(this, "description")} name="description_md" />
</label>
</div>
<div class="modal-footer modal-footer-buttons">
<CancelButton />
<button type="submit" class="primary" disabled={state.submitting}>
<Text id={state.submitting ? "saving" : "save"} />
</button>
</div>
</form>
</Modal>;
}
@bind
private submit(evt: Event): void {
evt.preventDefault();
// eslint-disable-next-line @typescript-eslint/camelcase
const body = {name: this.state.name, description_md: this.state.description};
this.setState({submitting: true}, () => {
bracketEdit(this.props.bracket.id, body)
.then(() => this.modal.current.dismiss())
.catch(console.error)
.then(() => this.setState({submitting: false}));
});
}
}
export function makePlayerMap(players: PlayerInfo[]) {
const playerMap: PlayerMap = {};
players.forEach(player => {
playerMap[player.id] = player;
});
return playerMap;
}