Making progress

This commit is contained in:
Brian Buller 2017-06-08 12:20:43 -05:00
parent abd7e803e9
commit 9a77a25342
21 changed files with 440 additions and 194 deletions

57
admin_clients.go Normal file
View File

@ -0,0 +1,57 @@
package main
import (
"fmt"
"net"
"net/http"
"github.com/gorilla/mux"
)
func handleAdminClients(w http.ResponseWriter, req *http.Request, page *pageData) {
vars := mux.Vars(req)
page.SubTitle = "Clients"
clientId := vars["id"]
clientIp, _, _ := net.SplitHostPort(req.RemoteAddr)
if clientId == "" {
type clientsPageData struct {
Clients []Client
}
page.TemplateData = clientsPageData{Clients: dbGetAllClients()}
page.SubTitle = "Clients"
page.show("admin-clients.html", w)
} else {
switch vars["function"] {
case "add":
type actClientPageData struct {
Id string
}
page.TemplateData = actClientPageData{Id: clientId}
page.show("admin-activateclient.html", w)
case "auth":
email := req.FormValue("email")
password := req.FormValue("password")
remember := req.FormValue("remember")
if doLogin(email, password) == nil {
// Received a valid login
// Authenticate the client
if dbAuthClient(clientId, clientIp) == nil {
page.session.setFlashMessage("Client Authenticated", "success")
} else {
page.session.setFlashMessage("Client Authentication Failed", "error")
}
if remember == "on" {
// Go ahead and log in
page.session.setStringValue("email", email)
redirect("/admin/clients", w, req)
}
}
redirect("/", w, req)
case "deauth":
remember := req.FormValue("remember")
fmt.Println("Remember: ", remember)
dbDeAuthClient(clientId)
redirect("/admin/clients", w, req)
}
}
}

47
admin_endpoints.go Normal file
View File

@ -0,0 +1,47 @@
package main
import (
"net/http"
"github.com/gorilla/mux"
)
func initAdminRequest(w http.ResponseWriter, req *http.Request) *pageData {
p := InitPageData(w, req)
p.Stylesheets = append(p.Stylesheets, "/assets/css/admin.css")
p.Scripts = append(p.Scripts, "/assets/js/admin.js")
p.HideAdminMenu = false
return p
}
// Main admin handler, routes the request based on the category
func handleAdmin(w http.ResponseWriter, req *http.Request) {
page := initAdminRequest(w, req)
vars := mux.Vars(req)
if !page.LoggedIn {
if vars["category"] == "clients" &&
vars["id"] != "" &&
(vars["function"] == "add" || vars["function"] == "auth") {
// When authenticating a client, we have an all-in-one login/auth page
handleAdminClients(w, req, page)
} else {
page.SubTitle = "Admin Login"
page.show("admin-login.html", w)
}
} else {
adminCategory := vars["category"]
switch adminCategory {
case "users":
handleAdminUsers(w, req, page)
case "teams":
handleAdminTeams(w, req, page)
case "games":
handleAdminGames(w, req, page)
case "clients":
handleAdminClients(w, req, page)
default:
page.show("admin-main.html", w)
}
}
}

6
admin_games.go Normal file
View File

@ -0,0 +1,6 @@
package main
import "net/http"
func handleAdminGames(w http.ResponseWriter, req *http.Request, page *pageData) {
}

70
admin_teams.go Normal file
View File

@ -0,0 +1,70 @@
package main
import (
"net/http"
"github.com/gorilla/mux"
)
func handleAdminTeams(w http.ResponseWriter, req *http.Request, page *pageData) {
vars := mux.Vars(req)
page.SubTitle = "Teams"
teamId := vars["id"]
if teamId == "new" {
switch vars["function"] {
case "save":
name := req.FormValue("teamname")
if dbIsValidTeam(name) {
// A team with that name already exists
page.session.setFlashMessage("A team with the name "+name+" already exists!", "error")
} else {
if err := dbCreateNewTeam(name); err != nil {
page.session.setFlashMessage(err.Error(), "error")
} else {
page.session.setFlashMessage("Team "+name+" created!", "success")
}
}
redirect("/admin/teams", w, req)
default:
page.SubTitle = "Add New Team"
page.show("admin-addteam.html", w)
}
} else if teamId != "" {
if dbIsValidTeam(teamId) {
switch vars["function"] {
case "save":
tm := new(Team)
tm.UUID = teamId
tm.Name = req.FormValue("teamname")
if err := dbUpdateTeam(teamId, tm); err != nil {
page.session.setFlashMessage("Error updating team: "+err.Error(), "error")
} else {
page.session.setFlashMessage("Team Updated!", "success")
}
redirect("/admin/teams", w, req)
case "delete":
var err error
if err = dbDeleteTeam(teamId); err != nil {
page.session.setFlashMessage("Error deleting team: "+err.Error(), "error")
}
redirect("/admin/teams", w, req)
default:
page.SubTitle = "Edit Team"
t := dbGetTeam(teamId)
page.TemplateData = t
page.show("admin-editteam.html", w)
}
} else {
page.session.setFlashMessage("Couldn't find the requested team, please try again.", "error")
redirect("/admin/teams", w, req)
}
} else {
type teamsPageData struct {
Teams []Team
}
page.TemplateData = teamsPageData{Teams: dbGetAllTeams()}
page.SubTitle = "Teams"
page.show("admin-teams.html", w)
}
}

View File

@ -1,62 +1,39 @@
package main
import (
"errors"
"net/http"
"strings"
"github.com/gorilla/mux"
)
func initAdminRequest(w http.ResponseWriter, req *http.Request) *pageData {
p := InitPageData(w, req)
p.Stylesheets = append(p.Stylesheets, "/assets/css/admin.css")
p.Scripts = append(p.Scripts, "/assets/js/admin.js")
return p
}
// handleAdmin
// Main admin handler, routes the request based on the category
func handleAdmin(w http.ResponseWriter, req *http.Request) {
page := initAdminRequest(w, req)
if !page.LoggedIn {
page.SubTitle = "Admin Login"
page.show("admin-login.html", w)
} else {
vars := mux.Vars(req)
adminCategory := vars["category"]
switch adminCategory {
case "users":
handleAdminUsers(w, req, page)
case "teams":
handleAdminTeams(w, req, page)
case "games":
handleAdminGames(w, req, page)
default:
page.show("admin-main.html", w)
}
}
}
// handleAdminDoLogin
// Verify the provided credentials, set up a cookie (if requested)
// and redirect back to /admin
// TODO: Set up the cookie
func handleAdminDoLogin(w http.ResponseWriter, req *http.Request) {
page := initAdminRequest(w, req)
// Fetch the login credentials
email := req.FormValue("email")
password := req.FormValue("password")
if email != "" && password != "" {
if err := dbCheckCredentials(email, password); err != nil {
if err := doLogin(email, password); err != nil {
page.session.setFlashMessage("Invalid Login", "error")
} else {
page.session.setStringValue("email", email)
}
} else {
page.session.setFlashMessage("Invalid Login", "error")
}
redirect("/admin", w, req)
}
// doLogin attempts to log in with the given email/password
// If it can't, it returns an error
func doLogin(email, password string) error {
if strings.TrimSpace(email) != "" && strings.TrimSpace(password) != "" {
return dbCheckCredentials(email, password)
}
return errors.New("Invalid Credentials")
}
// handleAdminDoLogout
// Expire the session
func handleAdminDoLogout(w http.ResponseWriter, req *http.Request) {
@ -136,64 +113,3 @@ func handleAdminUsers(w http.ResponseWriter, req *http.Request, page *pageData)
page.show("admin-users.html", w)
}
}
// handleAdminTeams
func handleAdminTeams(w http.ResponseWriter, req *http.Request, page *pageData) {
vars := mux.Vars(req)
page.SubTitle = "Teams"
teamId := vars["id"]
if teamId == "new" {
switch vars["function"] {
case "save":
name := req.FormValue("teamname")
if dbIsValidTeam(name) {
// A team with that name already exists
page.session.setFlashMessage("A team with the name "+name+" already exists!", "error")
} else {
if err := dbCreateNewTeam(name); err != nil {
page.session.setFlashMessage(err.Error(), "error")
} else {
page.session.setFlashMessage("Team "+name+" created!", "success")
}
}
redirect("/admin/teams", w, req)
default:
page.SubTitle = "Add New Team"
page.show("admin-addteam.html", w)
}
} else if teamId != "" {
if dbIsValidTeam(teamId) {
switch vars["function"] {
case "save":
page.session.setFlashMessage("Not implemented yet...", "success")
redirect("/admin/teams", w, req)
case "delete":
var err error
if err = dbDeleteTeam(teamId); err != nil {
page.session.setFlashMessage("Error deleting team: "+err.Error(), "error")
}
redirect("/admin/teams", w, req)
default:
page.SubTitle = "Edit Team"
t := dbGetTeam(teamId)
page.TemplateData = t
page.show("admin-editteam.html", w)
}
} else {
page.session.setFlashMessage("Couldn't find the requested team, please try again.", "error")
redirect("/admin/teams", w, req)
}
} else {
type teamsPageData struct {
Teams []Team
}
page.TemplateData = teamsPageData{Teams: dbGetAllTeams()}
page.SubTitle = "Teams"
page.show("admin-teams.html", w)
}
}
// handleAdminGames
func handleAdminGames(w http.ResponseWriter, req *http.Request, page *pageData) {
}

View File

@ -1,5 +1,5 @@
function showAdminPanel() {
function toggleAdminPanel() {
document.querySelector('#menu').classList.toggle('hidden');
}
@ -12,6 +12,6 @@ document.onkeydown = function(evt) {
isEscape = (evt.keyCode == 27);
}
if(isEscape) {
showAdminPanel();
toggleAdminPanel();
}
}

16
main.go
View File

@ -48,8 +48,11 @@ type pageData struct {
LoggedIn bool
Menu []menuItem
BottomMenu []menuItem
HideAdminMenu bool
session *pageSession
CurrentJam string
ClientID string
ClientIsAuth bool
TemplateData interface{}
}
@ -159,6 +162,13 @@ func initialize() {
fmt.Println("Error saving Current Jam")
}
}
jmNm, err := dbGetCurrentJam()
if err == nil {
fmt.Println("Current Jam Name: " + jmNm)
} else {
fmt.Println(err.Error())
}
}
func loggingHandler(h http.Handler) http.Handler {
@ -215,13 +225,19 @@ func InitPageData(w http.ResponseWriter, req *http.Request) *pageData {
p.BottomMenu = append(p.BottomMenu, menuItem{"Users", "/admin/users", "fa-user"})
p.BottomMenu = append(p.BottomMenu, menuItem{"Logout", "/admin/dologout", "fa-sign-out"})
} else {
p.BottomMenu = append(p.BottomMenu, menuItem{"Admin", "/admin", "fa-sign-in"})
}
p.HideAdminMenu = true
if p.CurrentJam, err = dbGetCurrentJam(); err != nil {
p.FlashMessage = "Error Loading Current GameJam: " + err.Error()
p.FlashClass = "error"
}
p.ClientID = p.session.getClientID()
p.ClientIsAuth = dbClientIsAuth(p.ClientID)
return p
}

View File

@ -1,6 +1,11 @@
package main
import "github.com/br0xen/boltease"
import (
"errors"
"strings"
"github.com/br0xen/boltease"
)
var db *boltease.DB
var dbOpened bool
@ -24,7 +29,7 @@ func initDatabase() error {
return err
}
// Create the path to the bucket to store jam informations
if err := db.MkBucketPath([]string{"jams"}); err != nil {
if err := db.MkBucketPath([]string{"jam"}); err != nil {
return err
}
// Create the path to the bucket to store site config data
@ -41,51 +46,25 @@ func dbSetCurrentJam(name string) error {
}
func dbHasCurrentJam() bool {
var nm string
var err error
if nm, err = dbGetCurrentJam(); err != nil {
if _, err = dbGetCurrentJam(); err != nil {
return false
}
ret, err := dbIsValidJam(nm)
return ret && err != nil
return true
}
func dbGetCurrentJam() (string, error) {
if err := db.OpenDB(); err != nil {
var ret string
var err error
if err = db.OpenDB(); err != nil {
return "", err
}
defer db.CloseDB()
return db.GetValue([]string{"site"}, "current-jam")
}
ret, err = db.GetValue([]string{"site"}, "current-jam")
func dbIsValidJam(name string) (bool, error) {
var err error
if err = db.OpenDB(); err != nil {
return false, err
if err == nil && strings.TrimSpace(ret) == "" {
return ret, errors.New("No Jam Name Specified")
}
defer db.CloseDB()
// Get all keys in the jams bucket
var keys []string
if keys, err = db.GetKeyList([]string{"jams", name}); err != nil {
return false, err
}
// All valid gamejams will have:
// "name"
// "teams"
for _, v := range []string{"name", "teams"} {
found := false
for j := range keys {
if keys[j] == v {
found = true
break
}
}
if !found {
// If we make it here, we didn't find a key we need
return false, nil
}
}
return true, nil
return ret, err
}

101
model_clients.go Normal file
View File

@ -0,0 +1,101 @@
package main
type Client struct {
UUID string
Auth bool
}
func dbGetAllClients() []Client {
var ret []Client
var err error
if err = db.OpenDB(); err != nil {
return ret
}
defer db.CloseDB()
var clientUids []string
if clientUids, err = db.GetBucketList([]string{"clients"}); err != nil {
return ret
}
for _, v := range clientUids {
if cl := dbGetClient(v); cl != nil {
ret = append(ret, *cl)
}
}
return ret
}
func dbGetClient(id string) *Client {
var err error
if err = db.OpenDB(); err != nil {
return nil
}
defer db.CloseDB()
cl := new(Client)
cl.UUID = id
cl.Auth = dbClientIsAuth(id)
return cl
}
func dbAddDeauthClient(cid, ip string) error {
var err error
if err = db.OpenDB(); err != nil {
return err
}
defer db.CloseDB()
err = db.SetBool([]string{"clients", cid}, "auth", false)
if err != nil {
return err
}
return db.SetValue([]string{"clients", cid}, "ip", ip)
}
func dbAuthClient(cid, ip string) error {
var err error
if err = db.OpenDB(); err != nil {
return err
}
defer db.CloseDB()
err = db.SetBool([]string{"clients", cid}, "auth", false)
if err != nil {
return err
}
return db.SetValue([]string{"clients", cid}, "ip", ip)
}
func dbDeAuthClient(cid string) error {
var err error
if err = db.OpenDB(); err != nil {
return err
}
defer db.CloseDB()
return db.SetBool([]string{"clients", cid}, "auth", false)
}
func dbClientIsAuth(cid string) bool {
var err error
if err = db.OpenDB(); err != nil {
return false
}
defer db.CloseDB()
var isAuth bool
if isAuth, err = db.GetBool([]string{"clients", cid}, "auth"); err != nil {
return false
}
return isAuth
}
func dbUpdateClientIP(cid, ip string) error {
var err error
if err = db.OpenDB(); err != nil {
return err
}
defer db.CloseDB()
return db.SetValue([]string{"clients", cid}, "ip", ip)
}

View File

@ -1,8 +1,6 @@
package main
import (
"fmt"
"github.com/pborman/uuid"
)
@ -28,99 +26,78 @@ func dbCreateNewTeam(nm string) error {
}
defer db.CloseDB()
var currJam string
if currJam, err = dbGetCurrentJam(); err != nil {
return err
}
// Generate a UUID
uuid := uuid.New()
teamPath := []string{"jams", currJam, "teams", uuid}
teamPath := []string{"teams", uuid}
if err := db.MkBucketPath(teamPath); err != nil {
fmt.Println("Error at 39: " + uuid)
return err
}
if err := db.SetValue(teamPath, "name", nm); err != nil {
fmt.Println("Error at 43")
return err
}
if err := db.MkBucketPath(append(teamPath, "members")); err != nil {
fmt.Println("Error at 47")
return err
}
gamePath := append(teamPath, "game")
if err := db.MkBucketPath(gamePath); err != nil {
fmt.Println("Error at 52")
return err
}
if err := db.SetValue(append(gamePath), "name", ""); err != nil {
fmt.Println("Error at 56")
return err
}
return db.MkBucketPath(append(gamePath, "screenshots"))
}
func dbIsValidTeam(nm string) bool {
func dbIsValidTeam(id string) bool {
var err error
var currJam string
if err = db.OpenDB(); err != nil {
return false
}
defer db.CloseDB()
if currJam, err = dbGetCurrentJam(); err != nil {
return false
}
teamPath := []string{"jams", currJam, "teams"}
teamPath := []string{"teams"}
if teamUids, err := db.GetBucketList(teamPath); err == nil {
for _, v := range teamUids {
if tstName, err := db.GetValue(append(teamPath, v), "name"); err == nil {
if tstName == nm {
if v == id {
return true
}
}
}
}
return false
}
func dbGetAllTeams() []Team {
var ret []Team
var err error
var currJam string
if err = db.OpenDB(); err != nil {
return ret
}
defer db.CloseDB()
if currJam, err = dbGetCurrentJam(); err != nil {
teamPath := []string{"teams"}
var teamUids []string
if teamUids, err = db.GetBucketList(teamPath); err != nil {
return ret
}
teamPath := []string{"jams", currJam, "teams"}
if teamUids, err := db.GetBucketList(teamPath); err != nil {
for _, v := range teamUids {
if tm := dbGetTeam(v); tm != nil {
ret = append(ret, *tm)
}
}
}
return ret
}
func dbGetTeam(id string) *Team {
var err error
var currJam string
if err = db.OpenDB(); err != nil {
return nil
}
defer db.CloseDB()
if currJam, err = dbGetCurrentJam(); err != nil {
return nil
}
teamPath := []string{"jams", currJam, "teams", id}
teamPath := []string{"teams", id}
tm := new(Team)
tm.UUID = id
if tm.Name, err = db.GetValue(teamPath, "name"); err != nil {
return nil
}
@ -129,16 +106,12 @@ func dbGetTeam(id string) *Team {
func dbGetTeamByName(nm string) *Team {
var err error
var currJam string
if err = db.OpenDB(); err != nil {
return nil
}
defer db.CloseDB()
if currJam, err = dbGetCurrentJam(); err != nil {
return nil
}
teamPath := []string{"jams", currJam, "teams"}
teamPath := []string{"teams"}
var teamUids []string
if teamUids, err = db.GetBucketList(teamPath); err != nil {
for _, v := range teamUids {
@ -151,17 +124,24 @@ func dbGetTeamByName(nm string) *Team {
return nil
}
func dbUpdateTeam(id string, tm *Team) error {
var err error
if err = db.OpenDB(); err != nil {
return nil
}
defer db.CloseDB()
teamPath := []string{"teams", id}
return db.SetValue(teamPath, "name", tm.Name)
}
func dbDeleteTeam(id string) error {
var err error
var currJam string
if err = db.OpenDB(); err != nil {
return err
}
defer db.CloseDB()
if currJam, err = dbGetCurrentJam(); err != nil {
return err
}
teamPath := []string{"jams", currJam, "teams"}
teamPath := []string{"teams"}
return db.DeleteBucket(teamPath, id)
}

View File

@ -5,6 +5,7 @@ import (
"net/http"
"github.com/gorilla/sessions"
"github.com/pborman/uuid"
)
// This is basically a convenience struct for
@ -30,6 +31,17 @@ func (p *pageSession) setStringValue(key, val string) {
p.session.Save(p.req, p.w)
}
func (p *pageSession) getClientID() string {
var clientId string
var err error
if clientId, err = p.getStringValue("client_id"); err != nil {
// No client id, generate and save one
clientId := uuid.New()
p.setStringValue("client_id", clientId)
}
return clientId
}
func (p *pageSession) setFlashMessage(msg, status string) {
p.setStringValue("flash_message", msg)
p.setStringValue("flash_status", status)

View File

@ -7,14 +7,18 @@ import (
func initPublicPage(w http.ResponseWriter, req *http.Request) *pageData {
p := InitPageData(w, req)
return p
}
func handleMain(w http.ResponseWriter, req *http.Request) {
page := initPublicPage(w, req)
page.SubTitle = "!"
page.SubTitle = ""
for _, tmpl := range []string{
"htmlheader.html",
"admin-menu.html",
"header.html",
"main.html",
"footer.html",
"htmlfooter.html",

View File

@ -0,0 +1,22 @@
<div class="center">
<form class="pure-form pure-form-aligned" action="/admin/clients/{{ .TemplateData.Id }}/auth" method="POST">
<fieldset>
<div class="pure-control-group">
<label for="email">Email Address</label>
<input id="email" name="email" type="text" placeholder="Email Address" autofocus>
</div>
<div class="pure-control-group">
<label for="password">Password</label>
<input id="password" name="password" type="password" placeholder="Password">
</div>
<div class="pure-controls">
<label for="remember" class="pure-checkbox">
<input id="remember" name="remember" type="checkbox"> Stay Logged In
</label>
</div>
<button type="submit" class="pure-button pure-button-primary">Submit</button>
</fieldset>
</form>
</div>

View File

@ -3,7 +3,7 @@
<fieldset>
<div class="pure-control-group">
<label for="teamname">Team Name</label>
<input id="teamname" name="teamname" type="text" placeholder="Team Name" value="">
<input id="teamname" name="teamname" type="text" placeholder="Team Name" value="" autofocus>
</div>
<button type="submit" class="pure-button pure-button-primary">Add Team</button>

View File

@ -3,7 +3,7 @@
<fieldset>
<div class="pure-control-group">
<label for="email">Email Address</label>
<input id="email" name="email" type="text" placeholder="Email Address" value="">
<input id="email" name="email" type="text" placeholder="Email Address" value="" autofocus>
</div>
<div class="pure-control-group">

View File

@ -0,0 +1,28 @@
<table id="clients-table" class="hidden sortable pure-table pure-table-bordered center">
<thead>
<tr>
<th>Client ID</th>
<th>Last Known IP</th>
</tr>
</thead>
<tbody>
{{ range $i, $v := .TemplateData.Clients }}
<tr>
<td>{{ $v.Id }}</td>
<td>{{ $v.Ip }}</td>
<td>
<a href="/admin/clients/{{ $v.UUID }}/deauth" class="pure-button pure-button-plain"><i class="fa fa-trash"></i></a>
</td>
</tr>
{{ end }}
</tbody>
</table>
<script>
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

@ -7,7 +7,7 @@
<div class="pure-control-group">
<label class="control-label" for="teamname">Team Name</label>
<input id="teamname" name="teamname" type="password" placeholder="Team Name">
<input id="teamname" name="teamname" value="{{.TemplateData.Name}}" placeholder="Team Name">
</div>
<div class="pure-control-group reset-pull">

View File

@ -3,7 +3,7 @@
<fieldset>
<div class="pure-control-group">
<label for="email">Email Address</label>
<input id="email" name="email" type="text" placeholder="Email Address">
<input id="email" name="email" type="text" placeholder="Email Address" autofocus>
</div>
<div class="pure-control-group">

View File

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

View File

@ -13,8 +13,8 @@
<tr>
<td>{{ $v.Name }}</td>
<td>
<a href="/admin/users/{{ $v.UUID }}/edit" class="pure-button pure-button-plain"><i class="fa fa-pencil"></i></a>
<a href="/admin/users/{{ $v.UUID }}/delete" class="pure-button pure-button-plain"><i class="fa fa-trash"></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>
</td>
</tr>
{{ end }}

View File

@ -1,9 +1,12 @@
<script>
var clientID = "{{.ClientID}}";
</script>
<div class="content">
<aside class="flash center {{.FlashClass}}">
{{.FlashMessage}}
</aside>
<div class="header">
ICT GameJam 2017
devICT Game Jam - {{.CurrentJam}}
</div>
<div class="header-menu">
<h2>{{.SubTitle}}</h2>