Merge pull request #6 from br0xen/master

Almost ready for the jam
This commit is contained in:
Brian Buller 2017-07-09 17:40:13 -05:00 committed by GitHub
commit 6ad85d68f7
71 changed files with 13945 additions and 225 deletions

View File

@ -1,2 +1,11 @@
# ictgj-voting # ictgj-voting
The ICT GameJam Voting Application The ICT GameJam Voting Application
pass in the `-dev` flag to enable development mode (load assets from file system instead of embedded).
Uses 'esc' for embedding assets:
https://github.com/mjibson/esc
TODO: Build Instructions
TODO: Contribution Instructions

View File

@ -55,3 +55,31 @@ func handleAdminClients(w http.ResponseWriter, req *http.Request, page *pageData
} }
} }
} }
func clientIsAuthenticated(cid string, req *http.Request) bool {
return clientIsServer(req) || dbClientIsAuth(cid)
}
func clientIsServer(req *http.Request) bool {
clientIp, _, _ := net.SplitHostPort(req.RemoteAddr)
ifaces, err := net.Interfaces()
if err == nil {
for _, i := range ifaces {
if addrs, err := i.Addrs(); err == nil {
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if clientIp == ip.String() {
return true
}
}
}
}
}
return false
}

View File

@ -2,6 +2,7 @@ package main
import ( import (
"net/http" "net/http"
"strconv"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -40,8 +41,25 @@ func handleAdmin(w http.ResponseWriter, req *http.Request) {
handleAdminGames(w, req, page) handleAdminGames(w, req, page)
case "clients": case "clients":
handleAdminClients(w, req, page) handleAdminClients(w, req, page)
case "votes":
handleAdminVotes(w, req, page)
case "mode":
handleAdminSetMode(w, req, page)
default: default:
page.TemplateData = getCondorcetResult()
page.show("admin-main.html", w) page.show("admin-main.html", w)
} }
} }
} }
func handleAdminSetMode(w http.ResponseWriter, req *http.Request, page *pageData) {
vars := mux.Vars(req)
newMode, err := strconv.Atoi(vars["id"])
if err != nil {
page.session.setFlashMessage("Invalid Mode: "+vars["id"], "error")
}
if dbSetPublicSiteMode(newMode) != nil {
page.session.setFlashMessage("Invalid Mode: "+vars["id"], "error")
}
redirect("/admin", w, req)
}

View File

@ -1,8 +1,10 @@
package main package main
import ( import (
"io/ioutil"
"net/http" "net/http"
"strings"
"encoding/base64"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -10,23 +12,51 @@ import (
func handleAdminGames(w http.ResponseWriter, req *http.Request, page *pageData) { func handleAdminGames(w http.ResponseWriter, req *http.Request, page *pageData) {
vars := mux.Vars(req) vars := mux.Vars(req)
page.SubTitle = "Games" page.SubTitle = "Games"
gameId := vars["id"] teamId := vars["id"]
teamId := req.FormValue("teamid") if teamId == "" {
if strings.TrimSpace(teamId) != "" { // Games List
page.session.setStringValue("teamid", teamId) type gamesPageData struct {
page.TeamID = teamId Teams []Team
} }
if gameId == "new" { gpd := new(gamesPageData)
gpd.Teams = dbGetAllTeams()
page.TemplateData = gpd
page.SubTitle = "Games"
page.show("admin-games.html", w)
} else {
switch vars["function"] { switch vars["function"] {
case "save": case "save":
name := req.FormValue("gamename") name := req.FormValue("gamename")
desc := req.FormValue("gamedesc")
if dbIsValidTeam(teamId) { if dbIsValidTeam(teamId) {
if dbEditTeamGame(teamId, name) != nil { if err := dbUpdateTeamGame(teamId, name, desc); err != nil {
page.session.setFlashMessage("Error updating game: "+err.Error(), "error")
} else {
page.session.setFlashMessage("Team game updated", "success")
} }
redirect("/admin/teams/"+teamId, w, req)
} }
default: case "screenshotupload":
page.SubTitle = "Add New Game" if err := saveScreenshots(teamId, req); err != nil {
page.show("admin-addgame.html", w) page.session.setFlashMessage("Error updating game: "+err.Error(), "error")
}
redirect("/admin/teams/"+teamId, w, req)
case "screenshotdelete":
ssid := vars["subid"]
if err := dbDeleteTeamGameScreenshot(teamId, ssid); err != nil {
page.session.setFlashMessage("Error deleting screenshot: "+err.Error(), "error")
}
redirect("/admin/teams/"+teamId, w, req)
} }
} }
} }
func saveScreenshots(teamId string, req *http.Request) error {
file, _, err := req.FormFile("newssfile")
if err != nil {
return err
}
data, err := ioutil.ReadAll(file)
str := base64.StdEncoding.EncodeToString(data)
return dbSaveTeamGameScreenshot(teamId, &Screenshot{Image: str})
}

View File

@ -6,11 +6,16 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
func refreshTeamsInMemory() {
site.Teams = dbGetAllTeams()
}
func handleAdminTeams(w http.ResponseWriter, req *http.Request, page *pageData) { func handleAdminTeams(w http.ResponseWriter, req *http.Request, page *pageData) {
vars := mux.Vars(req) vars := mux.Vars(req)
page.SubTitle = "Teams" page.SubTitle = "Teams"
teamId := vars["id"] teamId := vars["id"]
if teamId == "new" { if teamId == "new" {
// Add a new team
switch vars["function"] { switch vars["function"] {
case "save": case "save":
name := req.FormValue("teamname") name := req.FormValue("teamname")
@ -24,12 +29,14 @@ func handleAdminTeams(w http.ResponseWriter, req *http.Request, page *pageData)
page.session.setFlashMessage("Team "+name+" created!", "success") page.session.setFlashMessage("Team "+name+" created!", "success")
} }
} }
refreshTeamsInMemory()
redirect("/admin/teams", w, req) redirect("/admin/teams", w, req)
default: default:
page.SubTitle = "Add New Team" page.SubTitle = "Add New Team"
page.show("admin-addteam.html", w) page.show("admin-addteam.html", w)
} }
} else if teamId != "" { } else if teamId != "" {
// Functions for existing team
if dbIsValidTeam(teamId) { if dbIsValidTeam(teamId) {
switch vars["function"] { switch vars["function"] {
case "save": case "save":
@ -41,12 +48,17 @@ func handleAdminTeams(w http.ResponseWriter, req *http.Request, page *pageData)
} else { } else {
page.session.setFlashMessage("Team Updated!", "success") page.session.setFlashMessage("Team Updated!", "success")
} }
refreshTeamsInMemory()
redirect("/admin/teams", w, req) redirect("/admin/teams", w, req)
case "delete": case "delete":
var err error var err error
t := dbGetTeam(teamId)
if err = dbDeleteTeam(teamId); err != nil { if err = dbDeleteTeam(teamId); err != nil {
page.session.setFlashMessage("Error deleting team: "+err.Error(), "error") page.session.setFlashMessage("Error deleting team: "+err.Error(), "error")
} else {
page.session.setFlashMessage("Team "+t.Name+" Deleted", "success")
} }
refreshTeamsInMemory()
redirect("/admin/teams", w, req) redirect("/admin/teams", w, req)
case "savemember": case "savemember":
mbrName := req.FormValue("newmembername") mbrName := req.FormValue("newmembername")
@ -58,14 +70,17 @@ func handleAdminTeams(w http.ResponseWriter, req *http.Request, page *pageData)
} else { } else {
page.session.setFlashMessage(mbrName+" added to team!", "success") page.session.setFlashMessage(mbrName+" added to team!", "success")
} }
refreshTeamsInMemory()
redirect("/admin/teams/"+teamId, w, req) redirect("/admin/teams/"+teamId, w, req)
case "deletemember": case "deletemember":
mbrId := req.FormValue("memberid") mbrId := req.FormValue("memberid")
m, _ := dbGetTeamMember(teamId, mbrId)
if err := dbDeleteTeamMember(teamId, mbrId); err != nil { if err := dbDeleteTeamMember(teamId, mbrId); err != nil {
page.session.setFlashMessage("Error deleting team member: "+err.Error(), "error") page.session.setFlashMessage("Error deleting team member: "+err.Error(), "error")
} else { } else {
page.session.setFlashMessage("Member deleted from team", "success") page.session.setFlashMessage(m.Name+" deleted from team", "success")
} }
refreshTeamsInMemory()
redirect("/admin/teams/"+teamId, w, req) redirect("/admin/teams/"+teamId, w, req)
default: default:
page.SubTitle = "Edit Team" page.SubTitle = "Edit Team"
@ -78,10 +93,10 @@ func handleAdminTeams(w http.ResponseWriter, req *http.Request, page *pageData)
redirect("/admin/teams", w, req) redirect("/admin/teams", w, req)
} }
} else { } else {
// Team List
type teamsPageData struct { type teamsPageData struct {
Teams []Team Teams []Team
} }
page.TemplateData = teamsPageData{Teams: dbGetAllTeams()} page.TemplateData = teamsPageData{Teams: dbGetAllTeams()}
page.SubTitle = "Teams" page.SubTitle = "Teams"
page.show("admin-teams.html", w) page.show("admin-teams.html", w)

148
admin_votes.go Normal file
View File

@ -0,0 +1,148 @@
package main
import (
"errors"
"net/http"
"time"
"github.com/gorilla/mux"
)
// getCondorcetResult returns the ranking of teams based on the condorcet method
// https://en.wikipedia.org/wiki/Condorcet_method
func getCondorcetResult() []Team {
type teamPair struct {
winner *Team
loser *Team
majority float32
}
var allPairs []teamPair
var ret []Team
for i := 0; i < len(site.Teams); i++ {
for j := i + 1; j < len(site.Teams); j++ {
// For each pairing find a winner
winner, pct, _ := findWinnerBetweenTeams(&site.Teams[i], &site.Teams[j])
if winner != nil {
newPair := new(teamPair)
newPair.winner = winner
if winner.UUID == site.Teams[i].UUID {
newPair.loser = &site.Teams[j]
} else {
newPair.loser = &site.Teams[i]
}
newPair.majority = pct
allPairs = append(allPairs, *newPair)
}
}
}
teamWins := make(map[string]int)
for i := range site.Teams {
teamWins[site.Teams[i].UUID] = 0
}
for i := range allPairs {
teamWins[allPairs[i].winner.UUID]++
}
for len(teamWins) > 0 { //len(ret) <= len(site.Teams) {
topWins := 0
var topTeam string
for k, v := range teamWins {
// If this team is already in ret, carry on
if uuidIsInTeamSlice(k, ret) {
continue
}
// If this is the last key in teamWins, just add it
if len(teamWins) == 1 || v > topWins {
topWins = v
topTeam = k
}
}
// Remove topTeam from map
delete(teamWins, topTeam)
// Now add topTeam to ret
addTeam := site.getTeamByUUID(topTeam)
if addTeam != nil {
ret = append(ret, *addTeam)
} else {
break
}
}
return ret
}
// This is a helper function for calculating results
func uuidIsInTeamSlice(uuid string, sl []Team) bool {
for _, v := range sl {
if v.UUID == uuid {
return true
}
}
return false
}
// findWinnerBetweenTeams returns the team that got the most votes
// and the percentage of votes they received
// or an error if a winner couldn't be determined.
func findWinnerBetweenTeams(tm1, tm2 *Team) (*Team, float32, error) {
// tally gets incremented for a tm1 win, decremented for a tm2 win
var tm1votes, tm2votes float32
for _, v := range site.Votes {
for _, chc := range v.Choices {
if chc.Team == tm1.UUID {
tm1votes++
break
} else if chc.Team == tm2.UUID {
tm2votes++
break
}
}
}
ttlVotes := tm1votes + tm2votes
if tm1votes > tm2votes {
return tm1, 100 * (tm1votes / ttlVotes), nil
} else if tm1votes < tm2votes {
return tm2, 100 * (tm2votes / ttlVotes), nil
}
return nil, 50, errors.New("Unable to determine a winner")
}
func getInstantRunoffResult() []Team {
var ret []Team
return ret
}
func handleAdminVotes(w http.ResponseWriter, req *http.Request, page *pageData) {
vars := mux.Vars(req)
page.SubTitle = "Votes"
type vpdVote struct {
Timestamp string
ClientId string
Choices []Team
}
type votePageData struct {
AllVotes []vpdVote
Results []Team
}
vpd := new(votePageData)
for i := range site.Votes {
v := new(vpdVote)
v.Timestamp = site.Votes[i].Timestamp.Format(time.RFC3339)
v.ClientId = site.Votes[i].ClientId
for _, choice := range site.Votes[i].Choices {
for _, fndTm := range site.Teams {
if fndTm.UUID == choice.Team {
v.Choices = append(v.Choices, fndTm)
break
}
}
}
vpd.AllVotes = append(vpd.AllVotes, *v)
}
vpd.Results = getCondorcetResult()
page.TemplateData = vpd
switch vars["function"] {
default:
page.show("admin-votes.html", w)
}
}

12312
assets.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,3 +5,7 @@ div.content {
div.bottom-space { div.bottom-space {
margin-bottom: 15px; margin-bottom: 15px;
} }
img.thumbnail {
cursor: pointer;
}

View File

@ -4,7 +4,11 @@ html {
body { body {
color: #777; color: #777;
min-ehgiht: 100%; min-height: 100%;
}
a {
text-decoration: none;
} }
div.content { div.content {
@ -13,10 +17,44 @@ div.content {
color: black; color: black;
} }
div.half {
width: 50%;
}
.header { .header {
margin-left: 0; margin-left: 0;
} }
.primary {
color: #0078e7;
}
.primary-bg {
background-color: #0078e7;
}
input.file {
padding: .5em .6em;
display: inline-block;
border: 1px solid #ccc;
box-shadow: inset 0 1px 3px #ddd;
border-radius: 4px;
}
input.ranking-input {
width: 50px;
border-radius: 5px;
border: 1px solid #CCC;
text-align: center;
}
button.submit-vote {
}
i.move-icon {
cursor: ns-resize;
}
#menu { #menu {
width: 100%; width: 100%;
height: 40px; height: 40px;
@ -29,6 +67,14 @@ div.content {
display: inline-block; display: inline-block;
} }
#menu .pure-menu-nonlink {
color: #777;
padding: .5em 1em;
display: block;
text-decoration: none;
white-space: nowrap;
}
#menu .menu-button { #menu .menu-button {
display: inline; display: inline;
position: absolute; position: absolute;
@ -57,8 +103,117 @@ div.content {
background-color: #191818; background-color: #191818;
} }
input.uuid-field { div.horizontal-scroll {
width: 360px; overflow-x: scroll;
}
img.thumbnail {
height: 100px;
}
.thumbnail-container {
display: block;
height: 120px;
background-color: #EEE;
padding-top: 20px;
}
.padding {
padding: 5px;
}
.space {
margin: 5px;
}
.space-sides {
margin-left: 5px;
margin-right: 5px;
}
.space-vertical {
margin-top: 5px;
margin-bottom: 5px;
}
.big-space {
margin: 10px;
}
.left {
text-align: left;
}
.right {
text-align: right;
}
.center {
text-align: center;
}
.center-all {
text-align: center;
vertical-align: center;
margin: auto;
}
table.center tbody>td {
text-align: center;
}
table.padding td {
padding: 5px;
}
table tfoot {
border-top: 1px solid #CCC;
}
.pure-button-error {
background-color: #DD0000;
color: #FFF;
}
.pure-button-toggle-first {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
.pure-button-toggle-middle {
border-radius: 0px;
margin-left: -1px;
border-left: 1px solid #CCC;
}
.pure-button-toggle-last {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
margin-left: -1px;
border-left: 1px solid #CCC;
}
.pure-button:disabled {
background-color: #CCC;
color: #999;
}
#modal-overlay {
visibility: hidden;
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
text-align: center;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.5);
}
#modal-overlay>div {
width: 500px;
margin: 100px auto;
background-color: #FFF;
border: 1px solid #000;
border-radius: 10px;
padding: 15px;
text-align: center;
} }
@media (min-width: 40em) { @media (min-width: 40em) {
@ -92,6 +247,16 @@ input.uuid-field {
margin-left: 150px; margin-left: 150px;
} }
aside.flash.error {
background-color: #DD0000;
color: #FFFFFF;
}
aside.flash.success {
background-color: #229af9;
color: #FFFFFF;
}
.content { .content {
margin-left: 150px; margin-left: 150px;
} }

View File

@ -15,3 +15,52 @@ document.onkeydown = function(evt) {
toggleAdminPanel(); toggleAdminPanel();
} }
} }
function showModal(options) {
var modal = document.getElementById('modal-overlay');
document.getElementById('modal-title').innerText = (options.title)?options.title:"";
document.getElementById('modal-subtitle').innerText = (options.subtitle)?options.subtitle:"";
if(options.body) {
document.getElementById('modal-body').innerText = options.body;
} else if(options.bodyNode) {
document.getElementById('modal-body').appendChild(options.bodyNode);
}
if(options.buttons) {
for(var i = 0; i < options.buttons.length; i++) {
var btn;
if(options.buttons[i].isSubmit) {
btn = document.createElement('submit');
} else {
btn = document.createElement('a');
}
options.buttons[i].title = (options.buttons[i].title==undefined)?'':options.buttons[i].title;
options.buttons[i].href = (options.buttons[i].href==undefined)?'#':options.buttons[i].href;
options.buttons[i].click = (options.buttons[i].click==undefined)?function(){}:options.buttons[i].click;
options.buttons[i].class = (options.buttons[i].class==undefined)?'':options.buttons[i].class;
options.buttons[i].position = (options.buttons[i].position==undefined)?'right':options.buttons[i].position;
btn.innerHTML = options.buttons[i].title;
btn.title = options.buttons[i].title;
btn.href = options.buttons[i].href;
btn.className = 'space pure-button '+options.buttons[i].class+' '+options.buttons[i].position;
snack.listener(
{node:btn, event:'click'},
options.buttons[i].click
);
document.getElementById('modal-buttons').appendChild(btn);
}
}
modal.style.visibility = 'visible';
window.scrollTo(0, 0);
}
function hideModal() {
var modal = document.getElementById('modal-overlay');
modal.style.visibility = 'hidden';
document.getElementById('modal-title').innerHTML = '';
document.getElementById('modal-body').innerHTML = '';
var buttonsDiv = document.getElementById('modal-buttons')
while(buttonsDiv.firstChild) {
buttonsDiv.removeChild(buttonsDiv.firstChild);
}
}

View File

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 434 KiB

View File

@ -1 +0,0 @@
{"title":"ICT GameJam Voting","port":8080,"session":"ict-gamejam","dir":"./","devmode":true,"db":"gjvote.db","CurrentJam":""}

184
main.go
View File

@ -1,11 +1,11 @@
package main package main
//go:generate esc -o assets.go assets templates
import ( import (
"bufio" "bufio"
"encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -21,17 +21,29 @@ import (
) )
const AppName = "gjvote" const AppName = "gjvote"
const DbName = AppName + ".db"
// SiteData is stuff that stays the same // SiteData is stuff that stays the same
type siteData struct { type siteData struct {
Title string `json:"title"` Title string
Port int `json:"port"` Port int
SessionName string `json:"session"` SessionName string
ServerDir string `json:"dir"` ServerDir string
DevMode bool `json:"devmode"` DevMode bool
DB string `json:"db"`
CurrentJam string CurrentJam string
Teams []Team
Votes []Vote
}
func (s *siteData) getTeamByUUID(uuid string) *Team {
for i := range s.Teams {
if s.Teams[i].UUID == uuid {
return &s.Teams[i]
}
}
return nil
} }
// pageData is stuff that changes per request // pageData is stuff that changes per request
@ -51,10 +63,12 @@ type pageData struct {
HideAdminMenu bool HideAdminMenu bool
session *pageSession session *pageSession
CurrentJam string CurrentJam string
ClientID string ClientId string
ClientIsAuth bool ClientIsAuth bool
ClientIsServer bool
TeamID string TeamID string
PublicMode int
TemplateData interface{} TemplateData interface{}
} }
@ -69,23 +83,27 @@ var sessionSecret = "JCOP5e8ohkTcOzcSMe74"
var sessionStore = sessions.NewCookieStore([]byte(sessionSecret)) var sessionStore = sessions.NewCookieStore([]byte(sessionSecret))
var site *siteData var site *siteData
var r *mux.Router var r *mux.Router
var configFile string
func main() { func main() {
configFile = "./config.json"
loadConfig() loadConfig()
saveConfig() dbSaveSiteConfig(site)
initialize() initialize()
r = mux.NewRouter() r = mux.NewRouter()
r.StrictSlash(true) r.StrictSlash(true)
s := http.StripPrefix("/assets/", http.FileServer(http.Dir(site.ServerDir+"assets/"))) if site.DevMode {
r.PathPrefix("/assets/").Handler(s) fmt.Println("Operating in Development Mode")
}
//s := http.StripPrefix("/assets/", http.FileServer(FS(site.DevMode)))
//http.Dir(site.ServerDir+"assets/")))
//r.PathPrefix("/assets/").Handler(s)
r.PathPrefix("/assets/").Handler(http.FileServer(FS(site.DevMode)))
// Public Subrouter // Public Subrouter
pub := r.PathPrefix("/").Subrouter() pub := r.PathPrefix("/").Subrouter()
pub.HandleFunc("/", handleMain) pub.HandleFunc("/", handleMain)
pub.HandleFunc("/vote", handlePublicSaveVote)
// API Subrouter // API Subrouter
//api := r.PathPrefix("/api").Subtrouter() //api := r.PathPrefix("/api").Subtrouter()
@ -98,6 +116,7 @@ func main() {
admin.HandleFunc("/{category}", handleAdmin) admin.HandleFunc("/{category}", handleAdmin)
admin.HandleFunc("/{category}/{id}", handleAdmin) admin.HandleFunc("/{category}/{id}", handleAdmin)
admin.HandleFunc("/{category}/{id}/{function}", handleAdmin) admin.HandleFunc("/{category}/{id}/{function}", handleAdmin)
admin.HandleFunc("/{category}/{id}/{function}/{subid}", handleAdmin)
http.Handle("/", r) http.Handle("/", r)
@ -108,25 +127,47 @@ func main() {
} }
func loadConfig() { func loadConfig() {
site = new(siteData) site = dbGetSiteConfig()
// Defaults:
site.Title = "ICT GameJam"
site.Port = 8080
site.SessionName = "ict-gamejam"
site.ServerDir = "./"
site.DevMode = false
site.DB = AppName + ".db"
jsonInp, err := ioutil.ReadFile(configFile) if len(os.Args) > 1 {
if err == nil { for _, v := range os.Args {
assertError(json.Unmarshal(jsonInp, &site)) key := v
val := ""
eqInd := strings.Index(v, "=")
if eqInd > 0 {
// It's a key/val argument
key = v[:eqInd]
val = v[eqInd+1:]
}
switch key {
case "-title":
site.Title = val
fmt.Print("Set site title: ", site.Title, "\n")
case "-port":
var tryPort int
var err error
if tryPort, err = strconv.Atoi(val); err != nil {
fmt.Print("Invalid port given: ", val, " (Must be an integer)\n")
tryPort = site.Port
}
// TODO: Make sure a valid port number is given
site.Port = tryPort
case "-session-name":
site.SessionName = val
case "-server-dir":
// TODO: Probably check if the given directory is valid
site.ServerDir = val
case "-help", "-h", "-?":
printHelp()
done()
case "-dev":
site.DevMode = true
case "-reset-defaults":
resetToDefaults()
done()
}
} }
} }
func saveConfig() {
var jsonInp []byte
jsonInp, _ = json.Marshal(site)
assertError(ioutil.WriteFile(configFile, jsonInp, 0644))
} }
func initialize() { func initialize() {
@ -170,6 +211,10 @@ func initialize() {
} else { } else {
fmt.Println(err.Error()) fmt.Println(err.Error())
} }
// Load all votes into memory
site.Votes = dbGetAllVotes()
site.Teams = dbGetAllTeams()
} }
func loggingHandler(h http.Handler) http.Handler { func loggingHandler(h http.Handler) http.Handler {
@ -201,13 +246,13 @@ func InitPageData(w http.ResponseWriter, req *http.Request) *pageData {
p.Site = site p.Site = site
p.SubTitle = "GameJam Voting" p.SubTitle = "GameJam Voting"
p.Stylesheets = make([]string, 0, 0) p.Stylesheets = make([]string, 0, 0)
p.Stylesheets = append(p.Stylesheets, "/assets/css/pure-min.css") p.Stylesheets = append(p.Stylesheets, "/assets/vendor/css/pure-min.css")
p.Stylesheets = append(p.Stylesheets, "/assets/css/grids-responsive-min.css") p.Stylesheets = append(p.Stylesheets, "/assets/vendor/css/grids-responsive-min.css")
p.Stylesheets = append(p.Stylesheets, "/assets/font-awesome/css/font-awesome.min.css") p.Stylesheets = append(p.Stylesheets, "/assets/vendor/font-awesome/css/font-awesome.min.css")
p.Stylesheets = append(p.Stylesheets, "/assets/css/gjvote.css") p.Stylesheets = append(p.Stylesheets, "/assets/css/gjvote.css")
p.HeaderScripts = make([]string, 0, 0) p.HeaderScripts = make([]string, 0, 0)
p.HeaderScripts = append(p.HeaderScripts, "/assets/js/snack-min.js") p.HeaderScripts = append(p.HeaderScripts, "/assets/vendor/js/snack-min.js")
p.Scripts = make([]string, 0, 0) p.Scripts = make([]string, 0, 0)
p.Scripts = append(p.Scripts, "/assets/js/gjvote.js") p.Scripts = append(p.Scripts, "/assets/js/gjvote.js")
@ -220,9 +265,10 @@ func InitPageData(w http.ResponseWriter, req *http.Request) *pageData {
// Build the menu // Build the menu
if p.LoggedIn { if p.LoggedIn {
p.Menu = append(p.Menu, menuItem{"Admin", "/admin", "fa-key"}) p.Menu = append(p.Menu, menuItem{"Admin", "/admin", "fa-key"})
p.Menu = append(p.Menu, menuItem{"Votes", "/admin/votes", "fa-sticky-note"})
p.Menu = append(p.Menu, menuItem{"Teams", "/admin/teams", "fa-users"}) p.Menu = append(p.Menu, menuItem{"Teams", "/admin/teams", "fa-users"})
p.Menu = append(p.Menu, menuItem{"Games", "/admin/games", "fa-gamepad"}) p.Menu = append(p.Menu, menuItem{"Games", "/admin/games", "fa-gamepad"})
p.Menu = append(p.Menu, menuItem{"Votes", "/admin/votes", "fa-sticky-note"})
p.Menu = append(p.Menu, menuItem{"Clients", "/admin/clients", "fa-desktop"})
p.BottomMenu = append(p.BottomMenu, menuItem{"Users", "/admin/users", "fa-user"}) p.BottomMenu = append(p.BottomMenu, menuItem{"Users", "/admin/users", "fa-user"})
p.BottomMenu = append(p.BottomMenu, menuItem{"Logout", "/admin/dologout", "fa-sign-out"}) p.BottomMenu = append(p.BottomMenu, menuItem{"Logout", "/admin/dologout", "fa-sign-out"})
@ -236,10 +282,15 @@ func InitPageData(w http.ResponseWriter, req *http.Request) *pageData {
p.FlashClass = "error" p.FlashClass = "error"
} }
p.ClientID = p.session.getClientID() p.ClientId = p.session.getClientId()
p.ClientIsAuth = dbClientIsAuth(p.ClientID) p.ClientIsAuth = clientIsAuthenticated(p.ClientId, req)
p.ClientIsServer = clientIsServer(req)
// TeamID is for team self-administration
p.TeamID, _ = p.session.getStringValue("teamid") p.TeamID, _ = p.session.getStringValue("teamid")
// Public Mode
p.PublicMode = dbGetPublicSiteMode()
return p return p
} }
@ -263,24 +314,65 @@ func (p *pageData) show(tmplName string, w http.ResponseWriter) error {
// outputTemplate // outputTemplate
// Spit out a template // Spit out a template
func outputTemplate(tmplName string, tmplData interface{}, w http.ResponseWriter) error { func outputTemplate(tmplName string, tmplData interface{}, w http.ResponseWriter) error {
_, err := os.Stat("templates/" + tmplName) // TODO: Use embedded files for these... Hopefully?
if err == nil { n := "/templates/" + tmplName
t := template.New(tmplName) l := template.Must(template.New("layout").Parse(FSMustString(site.DevMode, n)))
t, _ = t.ParseFiles("templates/" + tmplName) t := template.Must(l.Parse(FSMustString(site.DevMode, n)))
return t.Execute(w, tmplData) return t.Execute(w, tmplData)
} }
return fmt.Errorf("WebServer: Cannot load template (templates/%s): File not found", tmplName)
}
// redirect can be used only for GET redirects // redirect can be used only for GET redirects
func redirect(url string, w http.ResponseWriter, req *http.Request) { func redirect(url string, w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, url, 303) http.Redirect(w, req, url, 303)
} }
func printOutput(out string) { func resetToDefaults() {
if site.DevMode { def := GetDefaultSiteConfig()
fmt.Print(out) fmt.Println("Reset settings to defaults?")
fmt.Print(site.Title, " -> ", def.Title, "\n")
fmt.Print(site.Port, " -> ", def.Port, "\n")
fmt.Print(site.SessionName, " -> ", def.SessionName, "\n")
fmt.Print(site.ServerDir, " -> ", def.ServerDir, "\n")
fmt.Println("Are you sure? (y/N): ")
reader := bufio.NewReader(os.Stdin)
conf, _ := reader.ReadString('\n')
conf = strings.ToUpper(strings.TrimSpace(conf))
if strings.HasPrefix(conf, "Y") {
if dbSaveSiteConfig(def) != nil {
errorExit("Error resetting to defaults")
} }
fmt.Println("Reset to defaults")
}
}
func printHelp() {
help := []string{
"Game Jam Voting Help",
" -help, -h, -? Print this message",
" -dev Development mode, load assets from file system",
" -port=<port num> Set the site port",
" -session-name=<session> Set the name of the session to be used",
" -server-dir=<directory> Set the server directory",
" This designates where the database will be saved",
" and where the app will look for files if you're",
" operating in 'development' mode (-dev)",
" -title=<title> Set the site title",
" -current-jam=<name> Change the name of the current jam",
" -reset-defaults Reset all configuration options to defaults",
"",
}
for _, v := range help {
fmt.Println(v)
}
}
func done() {
os.Exit(0)
}
func errorExit(msg string) {
fmt.Println(msg)
os.Exit(1)
} }
func assertError(err error) { func assertError(err error) {

View File

@ -10,11 +10,26 @@ import (
var db *boltease.DB var db *boltease.DB
var dbOpened int var dbOpened int
const (
SiteModeWaiting = iota
SiteModeVoting
SiteModeError
)
func GetDefaultSiteConfig() *siteData {
ret := new(siteData)
ret.Title = "ICT GameJam"
ret.Port = 8080
ret.SessionName = "ict-gamejam"
ret.ServerDir = "./"
return ret
}
func openDatabase() error { func openDatabase() error {
dbOpened += 1 dbOpened += 1
if dbOpened == 1 { if dbOpened == 1 {
var err error var err error
db, err = boltease.Create(site.DB, 0600, nil) db, err = boltease.Create(DbName, 0600, nil)
if err != nil { if err != nil {
return err return err
} }
@ -82,3 +97,64 @@ func dbGetCurrentJam() (string, error) {
} }
return ret, err return ret, err
} }
func dbGetSiteConfig() *siteData {
var ret *siteData
def := GetDefaultSiteConfig()
var err error
if err = openDatabase(); err != nil {
return def
}
defer closeDatabase()
ret = new(siteData)
siteConf := []string{"site"}
if ret.Title, err = db.GetValue(siteConf, "title"); err != nil {
ret.Title = def.Title
}
if ret.Port, err = db.GetInt(siteConf, "port"); err != nil {
ret.Port = def.Port
}
if ret.SessionName, err = db.GetValue(siteConf, "session-name"); err != nil {
ret.SessionName = def.SessionName
}
if ret.ServerDir, err = db.GetValue(siteConf, "server-dir"); err != nil {
ret.ServerDir = def.ServerDir
}
return ret
}
func dbSaveSiteConfig(dat *siteData) error {
var err error
if err = openDatabase(); err != nil {
return err
}
defer closeDatabase()
siteConf := []string{"site"}
if err = db.SetValue(siteConf, "title", dat.Title); err != nil {
return err
}
if err = db.SetInt(siteConf, "port", dat.Port); err != nil {
return err
}
if err = db.SetValue(siteConf, "session-name", dat.SessionName); err != nil {
return err
}
return db.SetValue(siteConf, "server-dir", dat.ServerDir)
}
func dbGetPublicSiteMode() int {
if ret, err := db.GetInt([]string{"site"}, "public-mode"); err != nil {
return SiteModeWaiting
} else {
return ret
}
}
func dbSetPublicSiteMode(mode int) error {
if mode < 0 || mode >= SiteModeError {
return errors.New("Invalid site mode")
}
return db.SetInt([]string{"site"}, "public-mode", mode)
}

View File

@ -3,6 +3,8 @@ package main
type Client struct { type Client struct {
UUID string UUID string
Auth bool Auth bool
Name string
IP string
} }
func dbGetAllClients() []Client { func dbGetAllClients() []Client {
@ -35,9 +37,32 @@ func dbGetClient(id string) *Client {
cl := new(Client) cl := new(Client)
cl.UUID = id cl.UUID = id
cl.Auth = dbClientIsAuth(id) cl.Auth = dbClientIsAuth(id)
cl.Name, _ = db.GetValue([]string{"clients", id}, "name")
cl.IP, _ = db.GetValue([]string{"clients", id}, "ip")
return cl return cl
} }
func dbSetClientName(cid, name string) error {
var err error
if err = db.OpenDB(); err != nil {
return nil
}
defer db.CloseDB()
err = db.SetValue([]string{"clients", cid}, "name", name)
return err
}
func dbGetClientName(cid string) string {
if err := db.OpenDB(); err != nil {
return ""
}
defer db.CloseDB()
name, _ := db.GetValue([]string{"clients", cid}, "name")
return name
}
func dbAddDeauthClient(cid, ip string) error { func dbAddDeauthClient(cid, ip string) error {
var err error var err error
if err = db.OpenDB(); err != nil { if err = db.OpenDB(); err != nil {

View File

@ -1,12 +1,147 @@
package main package main
import "github.com/pborman/uuid" import (
"errors"
"github.com/pborman/uuid"
)
type Game struct { type Game struct {
UUID *uuid.UUID
Name string Name string
TeamId string
Description string
Screenshots []Screenshot
} }
func dbIsValidGame(id string) bool { type Screenshot struct {
return true Description string
Image string
UUID string
}
func dbUpdateTeamGame(teamId, name, desc string) error {
var err error
if err = openDatabase(); err != nil {
return err
}
defer closeDatabase()
// Make sure the team is valid
tm := dbGetTeam(teamId)
if tm == nil {
return errors.New("Invalid team")
}
gamePath := []string{"teams", teamId, "game"}
if err := db.MkBucketPath(gamePath); err != nil {
return err
}
if name == "" {
name = tm.Name + "'s Game"
}
if err := db.SetValue(gamePath, "name", name); err != nil {
return err
}
if err := db.SetValue(gamePath, "description", desc); err != nil {
return err
}
if err := db.MkBucketPath(append(gamePath, "screenshots")); err != nil {
return err
}
return err
}
func dbGetAllGames() []Game {
var ret []Game
tms := dbGetAllTeams()
for i := range tms {
ret = append(ret, *dbGetTeamGame(tms[i].UUID))
}
return ret
}
func dbGetTeamGame(teamId string) *Game {
var err error
if err = openDatabase(); err != nil {
return nil
}
defer closeDatabase()
gamePath := []string{"teams", teamId, "game"}
gm := new(Game)
if gm.Name, err = db.GetValue(gamePath, "name"); err != nil {
gm.Name = ""
}
gm.TeamId = teamId
if gm.Description, err = db.GetValue(gamePath, "description"); err != nil {
gm.Description = ""
}
gm.Screenshots = dbGetTeamGameScreenshots(teamId)
return gm
}
// Screenshots are saved as base64 encoded pngs
func dbGetTeamGameScreenshots(teamId string) []Screenshot {
var ret []Screenshot
var err error
ssPath := []string{"teams", teamId, "game", "screenshots"}
var ssIds []string
if ssIds, err = db.GetBucketList(ssPath); err != nil {
return ret
}
for _, v := range ssIds {
if ss := dbGetTeamGameScreenshot(teamId, v); ss != nil {
ret = append(ret, *ss)
}
}
return ret
}
func dbGetTeamGameScreenshot(teamId, ssId string) *Screenshot {
var err error
ssPath := []string{"teams", teamId, "game", "screenshots", ssId}
ret := new(Screenshot)
ret.UUID = ssId
if ret.Description, err = db.GetValue(ssPath, "description"); err != nil {
return nil
}
if ret.Image, err = db.GetValue(ssPath, "image"); err != nil {
return nil
}
return ret
}
func dbSaveTeamGameScreenshot(teamId string, ss *Screenshot) error {
var err error
if err = openDatabase(); err != nil {
return nil
}
defer closeDatabase()
ssPath := []string{"teams", teamId, "game", "screenshots"}
// Generate a UUID for this screenshot
uuid := uuid.New()
ssPath = append(ssPath, uuid)
if err := db.MkBucketPath(ssPath); err != nil {
return err
}
if err := db.SetValue(ssPath, "description", ss.Description); err != nil {
return err
}
if err := db.SetValue(ssPath, "image", ss.Image); err != nil {
return err
}
return nil
}
func dbDeleteTeamGameScreenshot(teamId, ssId string) error {
var err error
if err = openDatabase(); err != nil {
return nil
}
defer closeDatabase()
ssPath := []string{"teams", teamId, "game", "screenshots"}
return db.DeleteBucket(ssPath, ssId)
} }

View File

@ -2,7 +2,6 @@ package main
import ( import (
"errors" "errors"
"fmt"
"github.com/pborman/uuid" "github.com/pborman/uuid"
) )
@ -105,6 +104,7 @@ func dbGetTeam(id string) *Team {
return nil return nil
} }
tm.Members, _ = dbGetTeamMembers(id) tm.Members, _ = dbGetTeamMembers(id)
tm.Game = dbGetTeamGame(id)
return tm return tm
} }
@ -213,14 +213,11 @@ func dbGetTeamMembers(teamid string) ([]TeamMember, error) {
for _, v := range memberUuids { for _, v := range memberUuids {
var mbr *TeamMember var mbr *TeamMember
if mbr, err = dbGetTeamMember(teamid, v); err == nil { if mbr, err = dbGetTeamMember(teamid, v); err == nil {
fmt.Println("Finding Team Members", teamid, mbr.Name)
ret = append(ret, *mbr) ret = append(ret, *mbr)
} }
} }
} else {
fmt.Println(err.Error())
} }
return ret, nil return ret, err
} }
func dbGetTeamMember(teamid, mbrid string) (*TeamMember, error) { func dbGetTeamMember(teamid, mbrid string) (*TeamMember, error) {

110
model_votes.go Normal file
View File

@ -0,0 +1,110 @@
package main
import (
"strconv"
"strings"
"time"
)
// A Choice is a ranking of a game in a vote
type GameChoice struct {
Team string // UUID of team
Rank int
}
// A Vote is a collection of game rankings
type Vote struct {
Timestamp time.Time
ClientId string // UUID of client
Choices []GameChoice
}
func dbGetAllVotes() []Vote {
var ret []Vote
var err error
if err = db.OpenDB(); err != nil {
return ret
}
defer db.CloseDB()
votesBkt := []string{"votes"}
var clients []string
if clients, err = db.GetBucketList(votesBkt); err != nil {
// Couldn't get the list of clients
return ret
}
for _, clid := range clients {
ret = append(ret, dbGetClientVotes(clid)...)
}
return ret
}
func dbGetClientVotes(clientId string) []Vote {
var ret []Vote
var err error
if err = db.OpenDB(); err != nil {
return ret
}
defer db.CloseDB()
var times []string
votesBkt := []string{"votes", clientId}
if times, err = db.GetBucketList(votesBkt); err != nil {
return ret
}
for _, t := range times {
var tm time.Time
if tm, err = time.Parse(time.RFC3339, t); err == nil {
var vt *Vote
if vt, err = dbGetVote(clientId, tm); err == nil {
ret = append(ret, *vt)
}
}
}
return ret
}
func dbGetVote(clientId string, timestamp time.Time) (*Vote, error) {
var err error
if err = db.OpenDB(); err != nil {
return nil, err
}
defer db.CloseDB()
vt := new(Vote)
vt.Timestamp = timestamp
vt.ClientId = clientId
votesBkt := []string{"votes", clientId, timestamp.Format(time.RFC3339)}
var choices []string
if choices, err = db.GetKeyList(votesBkt); err != nil {
// Couldn't find the vote...
return nil, err
}
for _, v := range choices {
ch := new(GameChoice)
var rank int
if rank, err = strconv.Atoi(v); err == nil {
ch.Rank = rank
ch.Team, err = db.GetValue(votesBkt, v)
vt.Choices = append(vt.Choices, *ch)
}
}
return vt, nil
}
func dbSaveVote(clientId string, timestamp time.Time, votes []string) error {
var err error
if err = db.OpenDB(); err != nil {
return nil
}
defer db.CloseDB()
// Make sure we don't clobber a duplicate vote
votesBkt := []string{"votes", clientId, timestamp.Format(time.RFC3339)}
for i := range votes {
if strings.TrimSpace(votes[i]) != "" {
db.SetValue(votesBkt, strconv.Itoa(i), votes[i])
}
}
return err
}

View File

@ -31,7 +31,7 @@ func (p *pageSession) setStringValue(key, val string) {
p.session.Save(p.req, p.w) p.session.Save(p.req, p.w)
} }
func (p *pageSession) getClientID() string { func (p *pageSession) getClientId() string {
var clientId string var clientId string
var err error var err error
if clientId, err = p.getStringValue("client_id"); err != nil { if clientId, err = p.getStringValue("client_id"); err != nil {

View File

@ -1,30 +1,64 @@
package main package main
import ( import (
"fmt"
"net/http" "net/http"
"strings"
"time"
) )
func initPublicPage(w http.ResponseWriter, req *http.Request) *pageData { func initPublicPage(w http.ResponseWriter, req *http.Request) *pageData {
p := InitPageData(w, req) p := InitPageData(w, req)
return p return p
} }
func handleMain(w http.ResponseWriter, req *http.Request) { func handleMain(w http.ResponseWriter, req *http.Request) {
page := initPublicPage(w, req) page := initPublicPage(w, req)
page.SubTitle = "" page.SubTitle = ""
switch dbGetPublicSiteMode() {
case SiteModeWaiting:
page.show("public-waiting.html", w)
case SiteModeVoting:
loadVotingPage(w, req)
}
}
for _, tmpl := range []string{ func loadVotingPage(w http.ResponseWriter, req *http.Request) {
"htmlheader.html", page := initPublicPage(w, req)
"admin-menu.html", type votingPageData struct {
"header.html", Teams []Team
"main.html", Timestamp string
"footer.html",
"htmlfooter.html",
} {
if err := outputTemplate(tmpl, page, w); err != nil {
fmt.Printf("%s\n", err)
} }
vpd := new(votingPageData)
vpd.Teams = dbGetAllTeams()
vpd.Timestamp = time.Now().Format(time.RFC3339)
page.TemplateData = vpd
page.show("public-voting.html", w)
} }
func handlePublicSaveVote(w http.ResponseWriter, req *http.Request) {
page := initPublicPage(w, req)
page.SubTitle = ""
// Check if we already have a vote for this client id/timestamp
ts := req.FormValue("timestamp")
timestamp, err := time.Parse(time.RFC3339, ts)
if err != nil {
page.session.setFlashMessage("Error parsing timestamp: "+ts, "error")
redirect("/", w, req)
}
if _, err := dbGetVote(page.ClientId, timestamp); err == nil {
// Duplicate vote... Cancel it.
page.session.setFlashMessage("Duplicate vote!", "error")
redirect("/", w, req)
}
// voteSlice is an ordered string slice of the voters preferences
voteCSV := req.FormValue("uservote")
voteSlice := strings.Split(voteCSV, ",")
if err := dbSaveVote(page.ClientId, timestamp, voteSlice); err != nil {
page.session.setFlashMessage("Error Saving Vote: "+err.Error(), "error")
}
if newVote, err := dbGetVote(page.ClientId, timestamp); err == nil {
site.Votes = append(site.Votes, *newVote)
}
redirect("/", w, req)
} }

View File

@ -5,45 +5,6 @@
<label for="teamname">Team Name</label> <label for="teamname">Team Name</label>
<input id="teamname" name="teamname" type="text" placeholder="Team Name" value="" autofocus> <input id="teamname" name="teamname" type="text" placeholder="Team Name" value="" autofocus>
</div> </div>
<h2>Members</h2>
<table>
<thead>
<th>Name</th>
<th>Slack ID</th>
<th>Twitter</th>
<th>Email</th>
<th></th>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
<td></td>
<td></td>
<td>
</td>
</tr>
</tbody>
</table>
<div class="team-member">
<div class="pure-control-group">
<label for="membername">Name</label>
<input id="membername" name="membername" type="text" placeholder="Name" value="">
</div>
<div class="pure-control-group">
<label for="memberslackid">Slack ID</label>
<input id="memberslackid" name="memberslackid" type="text" placeholder="@SlackID" value="">
</div>
<div class="pure-control-group">
<label for="membertwitter">Twitter</label>
<input id="membertwitter" name="membertwitter" type="text" placeholder="@TwitterID" value="">
</div>
<div class="pure-control-group">
<label for="memberemail">Email</label>
<input id="memberemail" name="memberemail" type="text" placeholder="user@email.com" value="">
</div>
</div>
<button type="submit" class="pure-button pure-button-primary">Add Team</button> <button type="submit" class="pure-button pure-button-primary">Add Team</button>
</fieldset> </fieldset>
</form> </form>

View File

@ -1,3 +1,7 @@
<div>The hosting server is always an authenticated client</div>
{{ if not .TemplateData.Clients }}
<div>No additional clients have been authenticated</div>
{{ else }}
<table id="clients-table" class="hidden sortable pure-table pure-table-bordered center"> <table id="clients-table" class="hidden sortable pure-table pure-table-bordered center">
<thead> <thead>
<tr> <tr>
@ -17,12 +21,4 @@
{{ end }} {{ end }}
</tbody> </tbody>
</table> </table>
<script> {{ end }}
snack.ready(function() {
var tableBody = document.querySelector("#clients-table>tbody");
if(tableBody.children.length>0) {
// Show the table
document.getElementById('clients-table').classList.remove('hidden');
}
});
</script>

View File

@ -1,26 +1,67 @@
{{ $uuid := .TemplateData.UUID }} {{ $uuid := .TemplateData.UUID }}
<div class="center"> <div class="center">
<form class="pure-form pure-form-aligned" action="/admin/teams/{{ .TemplateData.UUID }}/save" method="POST"> <div class="left">
<form class="pure-form pure-form-aligned" action="/admin/teams/{{ $uuid }}/save" method="POST">
<h3>Team Details</h3>
<fieldset> <fieldset>
<div class="pure-control-group"> <div class="left big-space">
<span>{{ .TemplateData.Name }}</span>
</div>
<div class="pure-control-group"> <div class="pure-control-group">
<label class="control-label" for="teamname">Team Name</label> <label class="control-label" for="teamname">Team Name</label>
<input id="teamname" name="teamname" value="{{ .TemplateData.Name }}" placeholder="Team Name"> <input id="teamname" name="teamname" value="{{ .TemplateData.Name }}" placeholder="Team Name">
</div> </div>
</div>
<div class="pure-control-group reset-pull"> <div class="pure-control-group reset-pull">
<a href="/admin/teams" class="pull-left space pure-button pure-button-plain">Cancel</a> <a href="/admin/teams" class="pull-left space pure-button pure-button-plain">Cancel</a>
<button type="submit" class="pull-right space pure-button pure-button-primary">Update</button> <button type="submit" class="pull-right space pure-button pure-button-primary">Update Team</button>
<button type="button" id="btnDeleteUser" class="pull-right space pure-button pure-button-error">Delete</button> <button type="button" id="btnDeleteTeam" class="pull-right space pure-button pure-button-error">Delete Team</button>
</div>
</fieldset>
</form>
<hr />
</div>
<form class="pure-form pure-form-aligned" action="/admin/games/{{ $uuid }}/save" method="POST">
<fieldset>
<div class="left big-space">
<a name="game" />
<h3>Team Game</h3>
<div class="pure-control-group">
<label class="control-label" for="gamename">Game Name</label>
<input id="gamename" name="gamename" value="{{ .TemplateData.Game.Name }}" placeholder="Game Name">
</div>
<div class="pure-control-group">
<label class="control-label" for="gamedesc">Description</label>
<textarea id="gamedesc" name="gamedesc" placeholder="Description...">{{ .TemplateData.Game.Description }}</textarea>
</div>
<div class="pure-control-group">
<label class="control-label">Screenshots</label>
<div class="center-all horizontal-scroll thumbnail-container" id="thumbnail-container">
{{ if not .TemplateData.Game.Screenshots }}
<a style="margin-top:40px;" class="center-all pure-button pure-button-primary" href="javascript:toggleUploadSSForm();">Upload Screenshot</a>
{{ else }}
{{ range $i, $v := .TemplateData.Game.Screenshots }}
<img data-teamid="{{ $uuid }}" data-ssid="{{ $v.UUID }}" class="thumbnail" alt="{{ $v.Description }}" src="data:image/png;base64,{{ $v.Image }}" />
{{ end }}
{{ end }}
</div>
{{ if .TemplateData.Game.Screenshots }}
<div class="right">
<a id="toggleUploadSSFormBtn" class="pure-button pure-button-primary" href="javascript:toggleUploadSSForm();">Upload Screenshot</a>
</div>
{{ end }}
</div>
</div>
<div class="pure-control-group reset-pull">
<a href="/admin/teams/{{ $uuid }}" class="pull-left space pure-button pure-button-plain">Cancel</a>
<button type="submit" class="pull-right space pure-button pure-button-primary">Update Game</button>
</div> </div>
</fieldset> </fieldset>
</form> </form>
<h2>Members</h2> <hr />
<table> <div class="left">
<h3>Team Members</h3>
<table class="center padding hide">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@ -44,35 +85,55 @@
<td> <td>
<form action="/admin/teams/{{ $uuid }}/deletemember" method="POST"> <form action="/admin/teams/{{ $uuid }}/deletemember" method="POST">
<input type="hidden" name="memberid" value="{{ $v.UUID }}"/> <input type="hidden" name="memberid" value="{{ $v.UUID }}"/>
<button type="submit" class="pure-button pure-button-plain"><i class="fa fa-trash"></i></button> <button type="submit" class="pure-button pure-button-error"><i class="fa fa-trash"></i></button>
</form> </form>
</td> </td>
</tr> </tr>
{{ end }} {{ end }}
<tr> <tr>
<td colspan="6" class="center">Add a new member</td> <td colspan="6">Add a new member</td>
</tr> </tr>
<tr> <tr>
<td colspan="6"> <td colspan="6" class="padding">
<form action="/admin/teams/{{ $uuid }}/savemember" method="POST"> <form class="pure-form" action="/admin/teams/{{ $uuid }}/savemember" method="POST">
<input id="newmembername" name="newmembername" value="" placeholder="Member Name" /> <div class="pure-control-group">
<input id="newmembername" name="newmembername" value="" placeholder="Member Name" autofocus />
<input id="newmemberslackid" name="newmemberslackid" value="" placeholder="@SlackID" /> <input id="newmemberslackid" name="newmemberslackid" value="" placeholder="@SlackID" />
<input id="newmembertwitter" name="newmembertwitter" value="" placeholder="@Twitter" /> <input id="newmembertwitter" name="newmembertwitter" value="" placeholder="@Twitter" />
<input id="newmemberemail" name="newmemberemail" value="" placeholder="user@email.com" /> <input id="newmemberemail" name="newmemberemail" value="" placeholder="user@email.com" />
<button type="submit" class="pull-right space pure-button pure-button-primary">Add</button> <button type="submit" class="pull-right space-sides pure-button pure-button-primary">Add</button>
</div>
</form> </form>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pure-control-group reset-pull">
<a href="/admin/teams" class="pull-left space pure-button pure-button-plain">Cancel</a>
</div>
</div>
<div id="uploadscreenshotform" style="display:none;">
<h3>Upload Screenshot</h3>
<form class="pure-form pure-form-aligned" action="/admin/games/{{ $uuid }}/screenshotupload" method="POST" enctype="multipart/form-data">
<div class="pure-control-group" style="margin-bottom:50px;">
<input class="file" type="file" name="newssfile" multiple>
</div>
<a href="javascript:hideModal();" class="pull-left space-sides pure-button">Cancel</a>
<button type="submit" class="pull-right space-sides pure-button pure-button-primary">Add</button>
</form>
</div>
<div id="editscreenshotform" style="display:none;">
<div id="editss-container" class="pure-control-group" style="margin-bottom:50px;">
</div>
</div>
<script> <script>
snack.listener( snack.listener(
{node:document.getElementById('btnDeleteTeam'),event:'click'}, {node:document.getElementById('btnDeleteTeam'),event:'click'},
function() { function() {
showModal({ showModal({
title: 'Delete Team', title: 'Delete Team',
subtitle: '({{ .TemplateData.Name }} - {{ $uuid}})', subtitle: '({{ .TemplateData.Name }})',
body: 'Are you sure? This cannot be undone.', body: 'Are you sure? This cannot be undone.',
buttons: [{ buttons: [{
title:'Cancel', title:'Cancel',
@ -87,4 +148,51 @@
}); });
} }
); );
snack.listener(
{
node:document.getElementById('thumbnail-container'),
event:'click',
delegate: function(node) {
return node.getElementsByTagName('img');
}
},
function() {
showEditScreenShotModal(snack.wrap(this)[0]);
}
);
function showEditScreenShotModal(img) {
var newImg = img.cloneNode();
var editSSForm = document.getElementById('editscreenshotform');
var cont = document.getElementById('editss-container');
while(cont.hasChildNodes()) {
cont.removeChild(cont.lastChild);
}
cont.appendChild(newImg);
showModal({
title: 'Edit Screenshot',
bodyNode: editSSForm,
buttons: [
{ title: 'Delete', class: 'pure-button-error', position: 'right',
click: function() {
window.location = "/admin/games/{{ $uuid }}/screenshotdelete/"+img.dataset.ssid;
}
},
{ title: 'Cancel', class: 'pure-button', position: 'right', click: hideModal }
]
});
editSSForm.style.display="block";
editSSForm.style.height="200px";
}
function toggleUploadSSForm() {
var uploadForm = document.getElementById('uploadscreenshotform');
showModal({
title: 'Upload Screenshot',
subtitle: '({{ .TemplateData.Name }})',
bodyNode: uploadForm
});
uploadForm.style.display="block";
document.getElementById('modal-body').style.height='165px';
}
</script> </script>

View File

@ -0,0 +1,22 @@
{{ if not .TemplateData.Teams }}
<div>No games have been created</div>
{{ else }}
<table id="games-table" class="sortable pure-table pure-table-bordered center">
<thead>
<tr>
<th>Game Name</th>
<th>Team Name</th>
<th>Screenshots</th>
</tr>
</thead>
<tbody>
{{ range $i, $v := .TemplateData.Teams }}
<tr>
<td>{{ $v.Game.Name }}</td>
<td>{{ $v.Name }}</td>
<td>{{ len $v.Game.Screenshots }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}

View File

@ -1,6 +1,24 @@
<div class="content"> <div class="content">
<button onclick="window.location.href='/admin/votes'">Votes</button> <div>
<button onclick="window.location.href='/admin/teams'">Teams</button> <h3>Public Mode</h3>
<button onclick="window.location.href='/admin/games'">Games</button> <button onclick="window.location.href='/admin/mode/0'" class="pure-button-toggle-first pure-button {{ if eq .PublicMode 0 }}pure-button-primary{{ end }}">Waiting</button>
<button onclick="window.location.href='/admin/users'">Users</button> <button onclick="window.location.href='/admin/mode/1'" class="pure-button-toggle-last pure-button {{ if eq .PublicMode 1 }}pure-button-primary{{ end }}">Voting</button>
</div>
{{ if eq .PublicMode 1 }}
<div>
<h3>Current Results</h3>
<ol>
{{ range $i, $v := .TemplateData }}
<li>{{ $v.Name }}</li>
{{ end }}
</ol>
</div>
{{ end }}
<div>
<h3>Admin Sections</h3>
<button class="pure-button" onclick="window.location.href='/admin/votes'">Votes</button>
<button class="pure-button" onclick="window.location.href='/admin/teams'">Teams</button>
<button class="pure-button" onclick="window.location.href='/admin/games'">Games</button>
<button class="pure-button" onclick="window.location.href='/admin/users'">Users</button>
</div>
</div> </div>

View File

@ -1,6 +1,5 @@
<div id="menu" class="{{if .HideAdminMenu}}hidden{{end}}"> <div id="menu" class="{{if .HideAdminMenu}}hidden{{end}}">
<div class="pure-menu"> <div class="pure-menu">
<a class="pure-menu-heading" href="/admin/">Admin</a>
<a href="#menu" class="menu-button"> <a href="#menu" class="menu-button">
<i class="fa fa-bars"></i> <i class="fa fa-bars"></i>
</a> </a>
@ -13,9 +12,11 @@
{{ end }} {{ end }}
</ul> </ul>
{{ if .ClientIsAuth }} {{ if .ClientIsAuth }}
<a href="/admin/clients/{{.ClientID}}/remove" class="pure-menu-link"><i class="fa fa-key"></i> DeAuth Client</a> {{ if not .ClientIsServer }}
<a href="/admin/clients/{{.ClientId}}/remove" class="pure-menu-link"><i class="fa fa-key"></i> DeAuth Client</a>
{{ end }}
{{ else }} {{ else }}
<a href="/admin/clients/{{.ClientID}}/add" class="pure-menu-link"><i class="fa fa-key"></i> Auth Client</a> <a href="/admin/clients/{{.ClientId}}/add" class="pure-menu-link"><i class="fa fa-key"></i> Auth Client</a>
{{ end }} {{ end }}
<ul class="pure-menu-list menu-bottom"> <ul class="pure-menu-list menu-bottom">
{{ range $k, $v := .BottomMenu }} {{ range $k, $v := .BottomMenu }}

View File

@ -1,12 +1,16 @@
<div class="bottom-space center"> <div class="bottom-space center">
<a id="btnAddTeam" class="pure-button pure-button-success" href="/admin/teams/new"><i class="fa fa-plus"></i> Add Team</a> <a id="btnAddTeam" class="pure-button pure-button-success" href="/admin/teams/new"><i class="fa fa-plus"></i> Add Team</a>
</div> </div>
<table id="teams-table" class="hidden sortable pure-table pure-table-bordered center"> {{ if not .TemplateData.Teams }}
<div>No teams have been created</div>
{{ else }}
<table id="teams-table" class="sortable pure-table pure-table-bordered center">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Key</th> <th>Key</th>
<th>Members</th> <th>Members</th>
<th>Game</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -16,6 +20,7 @@
<td>{{ $v.Name }}</td> <td>{{ $v.Name }}</td>
<td>{{ $v.UUID }}</td> <td>{{ $v.UUID }}</td>
<td>{{ len $v.Members }}</td> <td>{{ len $v.Members }}</td>
<td>{{ $v.Game.Name }}</td>
<td> <td>
<a href="/admin/teams/{{ $v.UUID }}/edit" class="pure-button pure-button-plain"><i class="fa fa-pencil"></i></a> <a href="/admin/teams/{{ $v.UUID }}/edit" class="pure-button pure-button-plain"><i class="fa fa-pencil"></i></a>
<a href="/admin/teams/{{ $v.UUID }}/delete" class="pure-button pure-button-plain"><i class="fa fa-trash"></i></a> <a href="/admin/teams/{{ $v.UUID }}/delete" class="pure-button pure-button-plain"><i class="fa fa-trash"></i></a>
@ -24,12 +29,4 @@
{{ end }} {{ end }}
</tbody> </tbody>
</table> </table>
<script> {{ end }}
snack.ready(function() {
var tableBody = document.querySelector("#teams-table>tbody");
if(tableBody.children.length>0) {
// Show the table
document.getElementById('teams-table').classList.remove('hidden');
}
});
</script>

View File

@ -0,0 +1,35 @@
<h2>Current Results</h2>
<ol>
{{ range $i, $v := .TemplateData.Results }}
<li>{{ $v.Name }}</li>
{{ end }}
</ol>
<table id="votes-table" class="sortable pure-table pure-table-bordered center">
<thead>
<tr>
<th>Timestamp</th>
<th>Client ID</th>
<th>Rankings</th>
</tr>
</thead>
<tbody>
{{ range $i, $v := .TemplateData.AllVotes }}
<tr>
<td>{{ $v.Timestamp }}</td>
<td>{{ $v.ClientId }}</td>
<td>
<ol>
{{ range $ci, $cv := $v.Choices }}
<li>{{ $cv.Name }}</li>
{{ end }}
</ol>
</td>
</tr>
{{ end }}
</tbody>
<tfoot>
<tr>
<td class="left" colspan="3">{{ len .Site.Votes }} Total Votes</td>
</tr>
</tfoot>
</table>

View File

@ -1,13 +1,12 @@
<script> <script>
var clientID = "{{.ClientID}}"; var clientId = "{{.ClientId}}";
</script> </script>
<div class="content">
<aside class="flash center {{.FlashClass}}"> <aside class="flash center {{.FlashClass}}">
{{.FlashMessage}} {{.FlashMessage}}
</aside> </aside>
<div class="header"> <div class="content">
devICT Game Jam - {{.CurrentJam}} {{ if .SubTitle }}
</div>
<div class="header-menu"> <div class="header-menu">
<h1>{{.SubTitle}}</h1> <h2>{{.SubTitle}}</h2>
</div> </div>
{{ end }}

View File

@ -1 +0,0 @@
<div>Default Public Facing Page</div>

View File

@ -0,0 +1,337 @@
{{ if not .TemplateData.Teams }}
<div>No games have been created</div>
{{ else }}
<div class="content">
Rank one or more games from your favorite to least favorite.
</div>
<div class="content">
<h2>Your Choices</h2>
<table id="ranked-table" class="pure-table pure-table-bordered center">
<thead>
<tr>
<th>Rank</th>
<th>Game Name</th>
<th>Team Name</th>
<th>Screenshots</th>
<th></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<div class="content">
<h2>Unranked Games</h2>
<table id="unranked-table" class="pure-table pure-table-bordered center">
<thead>
<tr>
<th></th>
<th>Game Name</th>
<th>Team Name</th>
<th>Screenshots</th>
</tr>
</thead>
<tbody>
{{ range $i, $v := .TemplateData.Teams }}
<tr id="teamrow-{{$v.UUID}}" data-teamid="{{$v.UUID}}">
<td class="unranked-actions"><a class="pure-button pure-button-primary" href="javascript:moveToRanked('{{$v.UUID}}');"><i class="fa fa-plus"></i> Add to Vote</a></td>
<td class="voting-col game-name">{{ $v.Game.Name }}</td>
<td class="voting-col team-name">{{ $v.Name }}</td>
<td class="voting-col game-screenshots" data-sscount="{{len $v.Game.Screenshots}}">
{{ if not $v.Game.Screenshots }}
<i class="fa fa-image"></i> (No Screenshots)
{{ else }}
<a class="primary" tabindex="-1" href="javascript:showScreenshots('{{$v.UUID}}');"><i class="fa fa-image"></i> ({{ len $v.Game.Screenshots }}) Click to View</a>
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
<div class="content half">
<form action="/vote">
<input id="uservote" type="hidden" name="uservote" value="" />
<input id="timestamp" type="hidden" name="timestamp" value="{{.TemplateData.Timestamp}}" />
<button class="pure-button pure-button-primary space-vertical pull-right" type="submit">Submit Vote!</button>
</form>
</div>
{{ range $i, $v := .TemplateData.Teams }}
<div class="pure-control-group" id="screenshots-{{ $v.UUID }}" style="display:none;">
<h3>{{ $v.Game.Name }} by {{ $v.Name }}</h3>
<label class="control-label">Click to view original size</label>
<div class="center-all horizontal-scroll thumbnail-container" id="thumbnail-container">
{{ range $imgi, $imgv := $v.Game.Screenshots }}
<a href="javascript:embiggenScreenshot(this.getElementsByTagName('img')[0]);">
<img data-teamid="{{ $v.UUID }}" data-ssid="{{ $imgv.UUID }}" class="thumbnail" alt="{{ $imgv.Description }}" src="data:image/png;base64,{{ $imgv.Image }}" />
</a>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
<script>
var teamNames = { };
{{ range $i, $v := .TemplateData.Teams }}
teamNames[{{$v.UUID}}] = { "team-name": "{{$v.Name}}", "game-name": "{{$v.Game.Name}}" };
{{ end }}
function showScreenshots(tmuuid) {
var screenshots = document.getElementById('screenshots-'+tmuuid).cloneNode(true);
screenshots.style.display='';
showModal({
title: 'Screenshots',
subtitle: teamNames[tmuuid].game-name,
bodyNode: screenshots,
buttons: [{
title: 'Done',
position: 'right',
click: hideModal
}]
});
}
function embiggenScreenshot(img) {
}
function updateView() {
updateButtonStates();
var rankedCells = snack.wrap('#ranked-table>tbody>tr>td.rank-cell');
for(var i = 0; i < rankedCells.length; i++) {
rankedCells[i].innerText = i+1;
}
setUserVoteValue();
}
function setUserVoteValue() {
document.getElementById('uservote').value=getRankedCSV();
}
// moveToRanked takes the uuid of a game/team and moves that game to the
// bottom of the 'ranked' table
function moveToRanked(tmUUID) {
// First, find the team's row
var rows = snack.wrap('#teamrow-'+tmUUID);
if(rows.length > 0) {
var row = rows[0];
var delCell = row.getElementsByClassName('unranked-actions');
if(delCell.length > 0) {
delCell = delCell[0];
}
delCell.remove();
var tbody = snack.wrap('#ranked-table>tbody')[0];
var rankTd = document.createElement('td');
rankTd.classList.add('rank-cell');
row.prepend(rankTd);
row.append(createRankedActionsTd(tmUUID));
tbody.append(row)
}
updateView();
}
function moveRankedUp(tmUUID) {
var rows = snack.wrap('#ranked-table>tbody>tr');
var numRows = rows.length;
var tbody = snack.wrap('#ranked-table>tbody')[0];
if(rows.length > 0) {
// Just loop through the rows adding them to the table
// if the _next_ row is the row for this team, add it now
for(var i = 0; i < numRows; i++) {
if(numRows > i && rows[i+1] != null
&& rows[i+1].dataset.teamid == tmUUID) {
// The next one is the one we're moving up
// Append the _next_ one, then this one
tbody.append(rows[i+1]);
tbody.append(rows[i]);
// Increment i manually, since we already added the next row
i++;
} else {
// Otherwise just add the row
tbody.append(rows[i]);
}
}
}
updateView();
}
function updateButtonStates() {
var upBtns = snack.wrap('.ranked-move-up');
for(var i = 0; i < upBtns.length; i++) {
if(i == 0) {
upBtns[i].disabled=true;
} else {
upBtns[i].disabled=false;
}
}
var downBtns = snack.wrap('.ranked-move-down');
for(var i = 0; i < downBtns.length; i++) {
if(i == downBtns.length-1) {
downBtns[i].disabled=true;
} else {
downBtns[i].disabled=false;
}
}
}
function moveRankedDown(tmUUID) {
var rows = snack.wrap('#ranked-table>tbody>tr');
var numRows = rows.length;
var tbody = snack.wrap('#ranked-table>tbody')[0];
if(numRows > 0) {
// Just loop through the rows adding them to the table
// When we hit the row for this team, delay it by one
for(var i = 0; i < numRows; i++) {
if(rows[i].dataset.teamid == tmUUID && numRows > i) {
// This is the one we're moving down
// Append the _next_ one, then this one
tbody.append(rows[i+1]);
tbody.append(rows[i]);
// Increment i manually, since we already added the next row
i++;
} else {
// Otherwise just add the row
tbody.append(rows[i]);
}
}
}
updateView();
}
// moveToRanked takes the uuid of a game/team and moves that game to the
// bottom of the 'unranked' table
function moveToUnranked(tmUUID) {
// First, find the team's row
var rows = snack.wrap('#teamrow-'+tmUUID);
if(rows.length > 0) {
var row = rows[0];
// Remove the cells we don't need
var actCell = row.getElementsByClassName('ranked-actions');
if(actCell.length > 0) {
actCell = actCell[0];
}
actCell.remove();
var rankTd = row.getElementsByClassName('rank-cell');
if(rankTd.length > 0) {
rankTd = rankTd[0];
}
rankTd.remove();
// Add the cells we do
row.prepend(createUnrankedActionsTd(tmUUID));
// And add the row to the unranked table
var tbody = snack.wrap('#unranked-table>tbody')[0];
tbody.append(row);
}
updateView();
}
// getUnranked returns an array of games that haven't been ranked yet
// (it builds the array from the 'unranked' table)
function getUnranked() {
return gameTableToArray('unranked');
}
// getRanked returns an array of games that the user has ranked
// (it builds the array from the 'ranked' table)
function getRanked() {
return gameTableToArray('ranked');
}
// Converts either the 'ranked' or 'unranked' table to an array of objects
// 'tbl' should be either 'ranked' or 'unranked'
function gameTableToArray(tbl) {
var ret = [];
snack.wrap('#'+tbl+'-table>tbody>tr').each(function(ele, idx) {
ret = ret.concat(getTeamObj(ele.dataset.teamid));
});
return ret;
}
// getTeamObj returns an object for team tmUUID from table
function getTeamObj(tmUUID) {
var ret = null;
var rows = snack.wrap('#teamrow-'+tmUUID);
if(rows.length > 0) {
var ele = rows[0];
ret = {
uuid: tmUUID,
name: ele.getElementsByClassName('game-name')[0].innerText,
teamName: ele.getElementsByClassName('team-name')[0].innerText,
ssCount: ele.getElementsByClassName('game-screenshots')[0].dataset.sscount
};
}
return ret;
}
// getRankedCSV pulls the getRanked array and just returns a CSV of
// the team IDs in ranked order
// (This is how the 'vote' post expects it)
function getRankedCSV() {
var r = getRanked();
var ret = "";
for(var i = 0; i < r.length; i++) {
ret = ret + r[i].uuid+",";
}
return ret;
}
// createRankedActionsTd creates the td that holds all of the action
// buttons for a 'ranked' table row
function createRankedActionsTd(tmUUID) {
var td = document.createElement('td');
td.classList.add('ranked-actions');
var upBtn = document.createElement('button');
upBtn.classList.add('ranked-move-up', 'pure-button', 'pure-button-toggle-first', 'pure-button-primary');
upBtn.innerHTML = '<i class="fa fa-arrow-up"></i> Move Up';
snack.listener({
node: upBtn,
event: 'click'
}, function() {
moveRankedUp(tmUUID);
});
td.appendChild(upBtn);
var dnBtn = document.createElement('button');
dnBtn.classList.add('ranked-move-down', 'pure-button', 'pure-button-toggle-middle', 'pure-button-primary');
dnBtn.innerHTML = '<i class="fa fa-arrow-down"></i> Move Down';
snack.listener({
node: dnBtn,
event: 'click'
}, function() {
moveRankedDown(tmUUID);
});
td.appendChild(dnBtn);
var delBtn = document.createElement('button');
delBtn.dataset.teamid=tmUUID
delBtn.classList.add('ranked-remove', 'pure-button', 'pure-button-toggle-last', 'pure-button-error');
delBtn.innerHTML = '<i class="fa fa-times"></i> Remove';
snack.listener({
node: delBtn,
event: 'click'
}, function (){
moveToUnranked(tmUUID);
});
td.appendChild(delBtn);
return td;
}
// createUnrankedActionsTd created the td that holds the 'Add to Vote' button
function createUnrankedActionsTd(tmUUID) {
var td = document.createElement('td');
td.classList.add('unranked-actions');
var addBtn = document.createElement('button');
addBtn.dataset.teamid=tmUUID
addBtn.classList.add('pure-button', 'pure-button-toggle-last', 'pure-button-primary');
addBtn.innerHTML = '<i class="fa fa-plus"></i> Add to Vote';
var params = {
node: addBtn,
event: 'click'
}
snack.listener(params, function (){
moveToRanked(addBtn.dataset.teamid);
})
td.appendChild(addBtn);
return td;
}
</script>

View File

@ -0,0 +1 @@
<div>ICT Game Jam</div>