<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link data-require="bootstrap-css@*" data-semver="3.3.6" rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.css" />
<script data-require="jquery@*" data-semver="2.2.0" src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js"></script>
<script data-require="react@*" data-semver="0.14.2" src="https://cdnjs.cloudflare.com/ajax/libs/react/0.14.2/react.min.js"></script>
<link rel="stylesheet" href="style.css" />
<script src="script.js"></script>
</head>
<body>
<div id="container" class="container-fluid"></div>
<script src="https://fb.me/react-0.13.3.js"></script>
<script src="https://fb.me/JSXTransformer-0.13.3.js"></script>
<script type="text/jsx" src="script.jsx"></script>
</body>
</html>
// Helper components
var AllMatchesFrame = React.createClass({
render: function() {
return (
<table id="matches-table" class="table table-bordered">
<thead>
<tr>
<th>Table</th>
<th>Home Team</th>
<th></th>
<th>Visiting Team</th>
<th></th>
<th>Status</th>
</tr>
</thead>
<tbody>
<MatchInfo main={this.props.main} />
</tbody>
</table>
);
}
});
var MatchInfo = React.createClass({
isPlayer1TurnToServe: function() {
var main = this.props.main;
var totalPoints = main.state.player1Points + main.state.player2Points;
var floorValue = Math.floor(totalPoints/main.state.consecutiveServes);
return this.isEven(floorValue);
},
isEven: function(n) {
return n % 2 == 0;
},
render: function() {
var main = this.props.main;
var cursorStyle = main.state.isMatchComplete ? "not-allowed" : "pointer";
var highlightPlayer1AsServer = this.isPlayer1TurnToServe() ? "mark" : "";
var highlightPlayer2AsServer = this.isPlayer1TurnToServe() ? "" : "mark";
return (
<tr id="match-info-frame">
<td className="table-name" onClick={main.changeTableName}>{main.state.tableName}</td>
<td className={"player-name " + highlightPlayer1AsServer} onClick={main.changePlayer1Name}>{main.state.player1Name}</td>
<td className="points" onClick={main.incrementPlayer1Score} style={{cursor: cursorStyle}}>{main.state.player1Points}</td>
<td className={"player-name " + highlightPlayer2AsServer} onClick={main.changePlayer2Name}>{main.state.player2Name}</td>
<td className="points" onClick={main.incrementPlayer2Score} style={{cursor: cursorStyle}}>{main.state.player2Points}</td>
<td className="match-status">{main.state.matchStatus}</td>
</tr>
);
}
});
var ButtonsFrame = React.createClass({
render: function() {
return (
<div id="button-frame">
<button className="btn btn-default" onClick={this.props.localOnAddMatchClick}>
Add Match
</button>
<button className="btn btn-danger" onClick={this.props.localOnClickReset}>
Restart Match
</button>
</div>
);
}
});
var RulesFrame = React.createClass({
render: function() {
return (
<div id="rules-frame" className="navbar navbar-fixed-bottom">
<span className="glyphicon glyphicon-cog"></span>
Points to win a game = {this.props.allState.pointsToWin},
Consecutive serves = {this.props.allState.consecutiveServes}
</div>
);
}
});
// Top level component
var Main = React.createClass({
getInitialState: function() {
return { tableName: "1",
player1Name: "Player 1",
player2Name: "Player 2",
player1Points: 0,
player2Points: 0,
matchStatus: "Awaiting start...",
isMatchComplete: false,
pointsToWin: 11,
consecutiveServes: 2
};
},
addMatch: function() {
//TODO: add a new match to table of matches with default player names
// bootbox.alert("Hi Tim");
},
changeTableName: function() {
var result = prompt("Input table name/number", this.state.tableName);
if (result != null) {
this.setState({tableName: result});
}
},
changePlayer1Name: function() {
var result = prompt("Input player name", this.state.player1Name);
if (result != null) {
this.setState({player1Name: result});
}
},
changePlayer2Name: function() {
var result = prompt("Input player name", this.state.player2Name);
if (result != null) {
this.setState({player2Name: result});
}
},
incrementPlayer1Score: function() {
this.updateMatchStatus(true);
},
incrementPlayer2Score: function() {
this.updateMatchStatus(false);
},
updateMatchStatus: function(wasPointScoredByPlayer1) {
//NOTE: we make one call to setState here, not several. Updating of state is async, so on the line of
// code after the setState call, the state may still be the OLD state!
if (this.state.isMatchComplete == false)
{
var isMatchComplete = false;
var newMatchStatus = this.state.matchStatus;
var wasPointScoredByPlayer2 = !wasPointScoredByPlayer1;
var newScoreForPlayer1 = wasPointScoredByPlayer1 ? this.state.player1Points + 1 : this.state.player1Points;
var newScoreForPlayer2 = wasPointScoredByPlayer2 ? this.state.player2Points + 1 : this.state.player2Points;
if (newScoreForPlayer1 >= this.state.pointsToWin && newScoreForPlayer1 - newScoreForPlayer2 > 1)
{
newMatchStatus = "Match complete - " + this.state.player1Name + " wins!";
isMatchComplete = true;
}
else if (newScoreForPlayer2 >= this.state.pointsToWin && newScoreForPlayer2 - newScoreForPlayer1 > 1)
{
newMatchStatus = "Match complete - " + this.state.player2Name + " wins!";
isMatchComplete = true;
}
else if (newScoreForPlayer1 > 0 || newScoreForPlayer2 > 0)
{
newMatchStatus = "Match in progress";
}
this.setState({player1Points: newScoreForPlayer1,
player2Points: newScoreForPlayer2,
matchStatus: newMatchStatus,
isMatchComplete: isMatchComplete
});
}
},
resetGame: function() {
this.replaceState(this.getInitialState());
},
render: function() {
return (
<div id="game">
<h3 className="text-center">Table Tennis Scoreboard (React.js learning)</h3>
<hr />
<AllMatchesFrame main={this} />
<ButtonsFrame localOnAddMatchClick={this.addMatch}
localOnClickReset={this.resetGame} />
<RulesFrame allState={this.state} />
</div>
);
}
});
React.render(
<Main />, document.getElementById('container')
);
#button-frame {
text-align: center;
margin-top: 20px;
}
.btn {
margin-right: 15px;
}
.points {
color: red;
background-color: #eee;
width: 50px;
height: 50px;
text-align: center;
font-size: 24px;
border-radius: 50%;
}
.table-name {
color: gray;
cursor: pointer;
}
.player-name {
color: gray;
cursor: pointer;
}
.match-status {
color: gray;
font-style: italic;
}
#matches-table {
width: 100%;
}
#rules-frame {
padding-left: 10px;
padding-right: 10px;
text-align: center;
}
body {
padding-bottom: 70px; /* Need this to stop body overlapping the rules-frame footer - DOESN'T WORK! */
/* prevent highlighting selection of text */
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-o-user-select: none;
user-select: none;
}
td, th {
text-align: center;
}
This is a simple project to help me learn React.js basics.
I've also used Bootstrap CSS to speed up styling the UI.
Note that the "Add Match" button functionality isn't implemented yet.
Future ideas:
* Add ability to view/edit scores from several tables (matches)
* Hook up to a Firebase backend so that mulitple users can view the scores in real-time and multiple users can update the score of the game they are watching.
* Keep track of number of games won
* Capture stats on how long each point is
* Visualise the game's history - see how the score changed through the game in a chart, did leading player keep changing?
* Add cooler style of dialogs for data input - using bootbox perhaps?
/**
* bootbox.js v4.4.0
*
* http://bootboxjs.com/license.txt
*/
!function(a,b){"use strict";"function"==typeof define&&define.amd?define(["jquery"],b):"object"==typeof exports?module.exports=b(require("jquery")):a.bootbox=b(a.jQuery)}(this,function a(b,c){"use strict";function d(a){var b=q[o.locale];return b?b[a]:q.en[a]}function e(a,c,d){a.stopPropagation(),a.preventDefault();var e=b.isFunction(d)&&d.call(c,a)===!1;e||c.modal("hide")}function f(a){var b,c=0;for(b in a)c++;return c}function g(a,c){var d=0;b.each(a,function(a,b){c(a,b,d++)})}function h(a){var c,d;if("object"!=typeof a)throw new Error("Please supply an object of options");if(!a.message)throw new Error("Please specify a message");return a=b.extend({},o,a),a.buttons||(a.buttons={}),c=a.buttons,d=f(c),g(c,function(a,e,f){if(b.isFunction(e)&&(e=c[a]={callback:e}),"object"!==b.type(e))throw new Error("button with key "+a+" must be an object");e.label||(e.label=a),e.className||(e.className=2>=d&&f===d-1?"btn-primary":"btn-default")}),a}function i(a,b){var c=a.length,d={};if(1>c||c>2)throw new Error("Invalid argument length");return 2===c||"string"==typeof a[0]?(d[b[0]]=a[0],d[b[1]]=a[1]):d=a[0],d}function j(a,c,d){return b.extend(!0,{},a,i(c,d))}function k(a,b,c,d){var e={className:"bootbox-"+a,buttons:l.apply(null,b)};return m(j(e,d,c),b)}function l(){for(var a={},b=0,c=arguments.length;c>b;b++){var e=arguments[b],f=e.toLowerCase(),g=e.toUpperCase();a[f]={label:d(g)}}return a}function m(a,b){var d={};return g(b,function(a,b){d[b]=!0}),g(a.buttons,function(a){if(d[a]===c)throw new Error("button key "+a+" is not allowed (options are "+b.join("\n")+")")}),a}var n={dialog:"<div class='bootbox modal' tabindex='-1' role='dialog'><div class='modal-dialog'><div class='modal-content'><div class='modal-body'><div class='bootbox-body'></div></div></div></div></div>",header:"<div class='modal-header'><h4 class='modal-title'></h4></div>",footer:"<div class='modal-footer'></div>",closeButton:"<button type='button' class='bootbox-close-button close' data-dismiss='modal' aria-hidden='true'>×</button>",form:"<form class='bootbox-form'></form>",inputs:{text:"<input class='bootbox-input bootbox-input-text form-control' autocomplete=off type=text />",textarea:"<textarea class='bootbox-input bootbox-input-textarea form-control'></textarea>",email:"<input class='bootbox-input bootbox-input-email form-control' autocomplete='off' type='email' />",select:"<select class='bootbox-input bootbox-input-select form-control'></select>",checkbox:"<div class='checkbox'><label><input class='bootbox-input bootbox-input-checkbox' type='checkbox' /></label></div>",date:"<input class='bootbox-input bootbox-input-date form-control' autocomplete=off type='date' />",time:"<input class='bootbox-input bootbox-input-time form-control' autocomplete=off type='time' />",number:"<input class='bootbox-input bootbox-input-number form-control' autocomplete=off type='number' />",password:"<input class='bootbox-input bootbox-input-password form-control' autocomplete='off' type='password' />"}},o={locale:"en",backdrop:"static",animate:!0,className:null,closeButton:!0,show:!0,container:"body"},p={};p.alert=function(){var a;if(a=k("alert",["ok"],["message","callback"],arguments),a.callback&&!b.isFunction(a.callback))throw new Error("alert requires callback property to be a function when provided");return a.buttons.ok.callback=a.onEscape=function(){return b.isFunction(a.callback)?a.callback.call(this):!0},p.dialog(a)},p.confirm=function(){var a;if(a=k("confirm",["cancel","confirm"],["message","callback"],arguments),a.buttons.cancel.callback=a.onEscape=function(){return a.callback.call(this,!1)},a.buttons.confirm.callback=function(){return a.callback.call(this,!0)},!b.isFunction(a.callback))throw new Error("confirm requires a callback");return p.dialog(a)},p.prompt=function(){var a,d,e,f,h,i,k;if(f=b(n.form),d={className:"bootbox-prompt",buttons:l("cancel","confirm"),value:"",inputType:"text"},a=m(j(d,arguments,["title","callback"]),["cancel","confirm"]),i=a.show===c?!0:a.show,a.message=f,a.buttons.cancel.callback=a.onEscape=function(){return a.callback.call(this,null)},a.buttons.confirm.callback=function(){var c;switch(a.inputType){case"text":case"textarea":case"email":case"select":case"date":case"time":case"number":case"password":c=h.val();break;case"checkbox":var d=h.find("input:checked");c=[],g(d,function(a,d){c.push(b(d).val())})}return a.callback.call(this,c)},a.show=!1,!a.title)throw new Error("prompt requires a title");if(!b.isFunction(a.callback))throw new Error("prompt requires a callback");if(!n.inputs[a.inputType])throw new Error("invalid prompt type");switch(h=b(n.inputs[a.inputType]),a.inputType){case"text":case"textarea":case"email":case"date":case"time":case"number":case"password":h.val(a.value);break;case"select":var o={};if(k=a.inputOptions||[],!b.isArray(k))throw new Error("Please pass an array of input options");if(!k.length)throw new Error("prompt with select requires options");g(k,function(a,d){var e=h;if(d.value===c||d.text===c)throw new Error("given options in wrong format");d.group&&(o[d.group]||(o[d.group]=b("<optgroup/>").attr("label",d.group)),e=o[d.group]),e.append("<option value='"+d.value+"'>"+d.text+"</option>")}),g(o,function(a,b){h.append(b)}),h.val(a.value);break;case"checkbox":var q=b.isArray(a.value)?a.value:[a.value];if(k=a.inputOptions||[],!k.length)throw new Error("prompt with checkbox requires options");if(!k[0].value||!k[0].text)throw new Error("given options in wrong format");h=b("<div/>"),g(k,function(c,d){var e=b(n.inputs[a.inputType]);e.find("input").attr("value",d.value),e.find("label").append(d.text),g(q,function(a,b){b===d.value&&e.find("input").prop("checked",!0)}),h.append(e)})}return a.placeholder&&h.attr("placeholder",a.placeholder),a.pattern&&h.attr("pattern",a.pattern),a.maxlength&&h.attr("maxlength",a.maxlength),f.append(h),f.on("submit",function(a){a.preventDefault(),a.stopPropagation(),e.find(".btn-primary").click()}),e=p.dialog(a),e.off("shown.bs.modal"),e.on("shown.bs.modal",function(){h.focus()}),i===!0&&e.modal("show"),e},p.dialog=function(a){a=h(a);var d=b(n.dialog),f=d.find(".modal-dialog"),i=d.find(".modal-body"),j=a.buttons,k="",l={onEscape:a.onEscape};if(b.fn.modal===c)throw new Error("$.fn.modal is not defined; please double check you have included the Bootstrap JavaScript library. See http://getbootstrap.com/javascript/ for more details.");if(g(j,function(a,b){k+="<button data-bb-handler='"+a+"' type='button' class='btn "+b.className+"'>"+b.label+"</button>",l[a]=b.callback}),i.find(".bootbox-body").html(a.message),a.animate===!0&&d.addClass("fade"),a.className&&d.addClass(a.className),"large"===a.size?f.addClass("modal-lg"):"small"===a.size&&f.addClass("modal-sm"),a.title&&i.before(n.header),a.closeButton){var m=b(n.closeButton);a.title?d.find(".modal-header").prepend(m):m.css("margin-top","-10px").prependTo(i)}return a.title&&d.find(".modal-title").html(a.title),k.length&&(i.after(n.footer),d.find(".modal-footer").html(k)),d.on("hidden.bs.modal",function(a){a.target===this&&d.remove()}),d.on("shown.bs.modal",function(){d.find(".btn-primary:first").focus()}),"static"!==a.backdrop&&d.on("click.dismiss.bs.modal",function(a){d.children(".modal-backdrop").length&&(a.currentTarget=d.children(".modal-backdrop").get(0)),a.target===a.currentTarget&&d.trigger("escape.close.bb")}),d.on("escape.close.bb",function(a){l.onEscape&&e(a,d,l.onEscape)}),d.on("click",".modal-footer button",function(a){var c=b(this).data("bb-handler");e(a,d,l[c])}),d.on("click",".bootbox-close-button",function(a){e(a,d,l.onEscape)}),d.on("keyup",function(a){27===a.which&&d.trigger("escape.close.bb")}),b(a.container).append(d),d.modal({backdrop:a.backdrop?"static":!1,keyboard:!1,show:!1}),a.show&&d.modal("show"),d},p.setDefaults=function(){var a={};2===arguments.length?a[arguments[0]]=arguments[1]:a=arguments[0],b.extend(o,a)},p.hideAll=function(){return b(".bootbox").modal("hide"),p};var q={bg_BG:{OK:"Ок",CANCEL:"Отказ",CONFIRM:"Потвърждавам"},br:{OK:"OK",CANCEL:"Cancelar",CONFIRM:"Sim"},cs:{OK:"OK",CANCEL:"Zrušit",CONFIRM:"Potvrdit"},da:{OK:"OK",CANCEL:"Annuller",CONFIRM:"Accepter"},de:{OK:"OK",CANCEL:"Abbrechen",CONFIRM:"Akzeptieren"},el:{OK:"Εντάξει",CANCEL:"Ακύρωση",CONFIRM:"Επιβεβαίωση"},en:{OK:"OK",CANCEL:"Cancel",CONFIRM:"OK"},es:{OK:"OK",CANCEL:"Cancelar",CONFIRM:"Aceptar"},et:{OK:"OK",CANCEL:"Katkesta",CONFIRM:"OK"},fa:{OK:"قبول",CANCEL:"لغو",CONFIRM:"تایید"},fi:{OK:"OK",CANCEL:"Peruuta",CONFIRM:"OK"},fr:{OK:"OK",CANCEL:"Annuler",CONFIRM:"D'accord"},he:{OK:"אישור",CANCEL:"ביטול",CONFIRM:"אישור"},hu:{OK:"OK",CANCEL:"Mégsem",CONFIRM:"Megerősít"},hr:{OK:"OK",CANCEL:"Odustani",CONFIRM:"Potvrdi"},id:{OK:"OK",CANCEL:"Batal",CONFIRM:"OK"},it:{OK:"OK",CANCEL:"Annulla",CONFIRM:"Conferma"},ja:{OK:"OK",CANCEL:"キャンセル",CONFIRM:"確認"},lt:{OK:"Gerai",CANCEL:"Atšaukti",CONFIRM:"Patvirtinti"},lv:{OK:"Labi",CANCEL:"Atcelt",CONFIRM:"Apstiprināt"},nl:{OK:"OK",CANCEL:"Annuleren",CONFIRM:"Accepteren"},no:{OK:"OK",CANCEL:"Avbryt",CONFIRM:"OK"},pl:{OK:"OK",CANCEL:"Anuluj",CONFIRM:"Potwierdź"},pt:{OK:"OK",CANCEL:"Cancelar",CONFIRM:"Confirmar"},ru:{OK:"OK",CANCEL:"Отмена",CONFIRM:"Применить"},sq:{OK:"OK",CANCEL:"Anulo",CONFIRM:"Prano"},sv:{OK:"OK",CANCEL:"Avbryt",CONFIRM:"OK"},th:{OK:"ตกลง",CANCEL:"ยกเลิก",CONFIRM:"ยืนยัน"},tr:{OK:"Tamam",CANCEL:"İptal",CONFIRM:"Onayla"},zh_CN:{OK:"OK",CANCEL:"取消",CONFIRM:"确认"},zh_TW:{OK:"OK",CANCEL:"取消",CONFIRM:"確認"}};return p.addLocale=function(a,c){return b.each(["OK","CANCEL","CONFIRM"],function(a,b){if(!c[b])throw new Error("Please supply a translation for '"+b+"'")}),q[a]={OK:c.OK,CANCEL:c.CANCEL,CONFIRM:c.CONFIRM},p},p.removeLocale=function(a){return delete q[a],p},p.setLocale=function(a){return p.setDefaults("locale",a)},p.init=function(c){return a(c||b)},p});