diff --git a/cmd/mothd/server.go b/cmd/mothd/server.go
index 7fe3849..5cf6582 100644
--- a/cmd/mothd/server.go
+++ b/cmd/mothd/server.go
@@ -169,7 +169,6 @@ func (mh *MothRequestHandler) ThemeOpen(path string) (ReadSeekCloser, time.Time,
// Register associates a team name with a team ID.
func (mh *MothRequestHandler) Register(teamName string) error {
- // BUG(neale): Register returns an error if a team is already registered; it may make more sense to return success
if teamName == "" {
return fmt.Errorf("empty team name")
}
diff --git a/theme/background.mjs b/theme/background.mjs
index 76b975d..61bd76e 100644
--- a/theme/background.mjs
+++ b/theme/background.mjs
@@ -2,9 +2,9 @@ function randint(max) {
return Math.floor(Math.random() * max)
}
-const MILLISECOND = 1
-const SECOND = MILLISECOND * 1000
-const FRAMERATE = 24 / SECOND // Fast enough for this tomfoolery
+const Millisecond = 1
+const Second = Millisecond * 1000
+const FrameRate = 24 / Second // Fast enough for this tomfoolery
class Point {
constructor(x, y) {
@@ -88,7 +88,7 @@ class QixLine {
* like the video game "qix"
*/
class QixBackground {
- constructor(ctx, frameRate = 6/SECOND) {
+ constructor(ctx, frameRate = 6/Second) {
this.ctx = ctx
this.min = new Point(0, 0)
this.max = new Point(this.ctx.canvas.width, this.ctx.canvas.height)
@@ -110,7 +110,7 @@ class QixBackground {
new Point(1 + randint(this.box.x / 100), 1 + randint(this.box.y / 100)),
)
- this.frameInterval = MILLISECOND / frameRate
+ this.frameInterval = Millisecond / frameRate
this.nextFrame = 0
}
@@ -149,6 +149,12 @@ class QixBackground {
}
function init() {
+ // Don't like the background animation? You can disable it by setting a
+ // property in localStorage and reloading.
+ if (localStorage.disableBackgroundAnimation) {
+ return
+ }
+
let canvas = document.createElement("canvas")
canvas.width = 640
canvas.height = 640
@@ -159,7 +165,7 @@ function init() {
let qix = new QixBackground(ctx)
// window.requestAnimationFrame is overkill for something this silly
- setInterval(() => qix.Animate(), MILLISECOND/FRAMERATE)
+ setInterval(() => qix.Animate(), Millisecond/FrameRate)
}
if (document.readyState === "loading") {
diff --git a/theme/basic.css b/theme/basic.css
index ffc71b9..4b09947 100644
--- a/theme/basic.css
+++ b/theme/basic.css
@@ -29,6 +29,15 @@ a:any-link {
background: red;
color: white;
}
+.toast {
+ background: #333;
+ color: #eee;
+ box-shadow: 0px 0px 8px 0px #0b0;
+}
+.debug {
+ background: #ccc;
+ color: black;
+}
@media (prefers-color-scheme: light) {
body {
background: #b9cbd8;
@@ -45,7 +54,8 @@ a:any-link {
body {
font-family: sans-serif;
background-image: url("bg.png");
- background-size: contain;
+ background-size: cover;
+ background-position: center;
background-blend-mode: soft-light;
background-attachment: fixed;
}
@@ -62,7 +72,7 @@ canvas.wallpaper {
}
main {
max-width: 40em;
- margin: auto;
+ margin: 1em auto;
padding: 1px 3px;
border-radius: 5px;
}
@@ -85,19 +95,43 @@ input, select {
padding: 0 1em;
border-radius: 8px;
}
+.hidden {
+ display: none;
+}
+/** Puzzles list */
+.category {
+ margin: 5px 0;
+ background: #ccc4;
+}
+.category h2 {
+ margin: 0 0.2em;
+}
nav ul, .category ul {
- padding: 1em;
+ margin: 0;
+ padding: 0.2em 1em;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 16px;
}
nav li, .category li {
display: inline;
- margin: 1em;
}
-iframe#body {
- border: inherit;
- width: 100%;
+.mothball {
+ float: right;
+ text-decoration: none;
+ border-radius: 5px;
+ background: #ccc;
+ padding: 4px 8px;
+ margin: 5px;
}
-img {
+
+/** Puzzle content */
+#puzzle {
+ border-bottom: solid;
+ padding: 0 0.5em;
+}
+#puzzle img {
max-width: 100%;
}
input:invalid {
@@ -106,9 +140,8 @@ input:invalid {
.answer_ok {
cursor: help;
}
-#messages {
- min-height: 3em;
-}
+
+/** Scoreboard */
#rankings {
width: 100%;
position: relative;
@@ -138,11 +171,17 @@ input:invalid {
.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
-
-#devel {
+.debug {
overflow: auto;
+ padding: 1em;
+ border-radius: 10px;
+ margin: 2em auto;
+}
+.debug dt {
+ font-weight: bold;
}
+/** Draggable items, from the draggable plugin */
li[draggable]::before {
content: "↕";
padding: 0.5em;
@@ -160,6 +199,28 @@ li[draggable] {
border: 1px white dashed;
}
-#cacheButton.disabled {
- display: none;
+
+
+
+
+/** Toasts are little pop-up informational messages. */
+ .toasts {
+ position: fixed;
+ z-index: 100;
+ bottom: 10px;
+ left: 10px;
+ text-align: center;
+ width: calc(100% - 20px);
+ display: flex;
+ flex-direction: column;
+}
+.toast {
+ border-radius: 0.5em;
+ padding: 0.2em 2em;
+ animation: fadeIn ease 1s;
+ margin: 2px auto;
+}
+@keyframes fadeIn {
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
}
diff --git a/theme/common.mjs b/theme/common.mjs
new file mode 100644
index 0000000..3b8d0c2
--- /dev/null
+++ b/theme/common.mjs
@@ -0,0 +1,43 @@
+/**
+ * Common functionality
+ */
+const Millisecond = 1
+const Second = Millisecond * 1000
+const Minute = Second * 60
+
+/**
+ * Display a transient message to the user.
+ *
+ * @param {String} message Message to display
+ * @param {Number} timeout How long before removing this message
+ */
+function Toast(message, timeout=5*Second) {
+ console.info(message)
+ for (let toasts of document.querySelectorAll(".toasts")) {
+ let p = toasts.appendChild(document.createElement("p"))
+ p.classList.add("toast")
+ p.textContent = message
+ setTimeout(() => p.remove(), timeout)
+ }
+}
+
+/**
+ * Run a function when the DOM has been loaded.
+ *
+ * @param {function():void} cb Callback function
+ */
+function WhenDOMLoaded(cb) {
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", cb)
+ } else {
+ cb()
+ }
+}
+
+export {
+ Millisecond,
+ Second,
+ Minute,
+ Toast,
+ WhenDOMLoaded,
+}
diff --git a/theme/index.html b/theme/index.html
index c7c8770..4457033 100644
--- a/theme/index.html
+++ b/theme/index.html
@@ -1,32 +1,34 @@
-
+
MOTH
-
-
+
+
- MOTH
+ MOTH
-
+
-
-
+
+
+
diff --git a/theme/index.mjs b/theme/index.mjs
new file mode 100644
index 0000000..adc9e7a
--- /dev/null
+++ b/theme/index.mjs
@@ -0,0 +1,158 @@
+/**
+ * Functionality for index.html (Login / Puzzles list)
+ */
+import * as moth from "./moth.mjs"
+import * as common from "./common.mjs"
+
+class App {
+ constructor(basePath=".") {
+ this.server = new moth.Server(basePath)
+
+ let uuid = Math.floor(Math.random() * 1000000).toString(16)
+ this.fakeRegistration = {
+ TeamId: uuid,
+ TeamName: `Team ${uuid}`,
+ }
+
+ for (let form of document.querySelectorAll("form.login")) {
+ form.addEventListener("submit", event => this.handleLoginSubmit(event))
+ }
+ for (let e of document.querySelectorAll(".logout")) {
+ e.addEventListener("click", () => this.Logout())
+ }
+
+ setInterval(() => this.Update(), common.Minute/3)
+ this.Update()
+ }
+
+ handleLoginSubmit(event) {
+ event.preventDefault()
+ console.log(event)
+ }
+
+ /**
+ * Attempt to log in to the server.
+ *
+ * @param {String} teamId
+ * @param {String} teamName
+ */
+ async Login(teamId, teamName) {
+ try {
+ await this.server.Login(teamId, teamName)
+ common.Toast(`Logged in (team id = ${teamId})`)
+ this.Update()
+ }
+ catch (error) {
+ common.Toast(error)
+ }
+ }
+
+ /**
+ * Log out of the server by clearing the saved Team ID.
+ */
+ async Logout() {
+ try {
+ this.server.Reset()
+ common.Toast("Logged out")
+ this.Update()
+ }
+ catch (error) {
+ common.Toast(error)
+ }
+ }
+
+ /**
+ * Update the entire page.
+ *
+ * Fetch a new state, and rebuild all dynamic elements on this bage based on
+ * what's returned. If we're in development mode and not logged in, auto
+ * login too.
+ */
+ async Update() {
+ this.state = await this.server.GetState()
+ for (let e of document.querySelectorAll(".messages")) {
+ e.innerHTML = this.state.Messages
+ }
+
+ for (let e of document.querySelectorAll(".login")) {
+ this.renderLogin(e, !this.server.LoggedIn())
+ }
+ for (let e of document.querySelectorAll(".puzzles")) {
+ this.renderPuzzles(e, this.server.LoggedIn())
+ }
+
+ if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
+ common.Toast("Automatically logging in to devel server")
+ console.info("Logging in with generated Team ID and Team Name", this.fakeRegistration)
+ return this.Login(this.fakeRegistration.TeamId, this.fakeRegistration.TeamName)
+ }
+ }
+
+ /**
+ * Render a login box.
+ *
+ * This just toggles visibility, there's nothing dynamic in a login box.
+ */
+ renderLogin(element, visible) {
+ element.classList.toggle("hidden", !visible)
+ }
+
+ /**
+ * Render a puzzles box.
+ *
+ * This updates the list of open puzzles, and adds mothball download links
+ * if the server is in development mode.
+ */
+ renderPuzzles(element, visible) {
+ element.classList.toggle("hidden", !visible)
+ while (element.firstChild) element.firstChild.remove()
+ for (let cat of this.state.Categories()) {
+ let pdiv = element.appendChild(document.createElement("div"))
+ pdiv.classList.add("category")
+
+ let h = pdiv.appendChild(document.createElement("h2"))
+ h.textContent = cat
+
+ // Extras if we're running a devel server
+ if (this.state.DevelopmentMode()) {
+ let a = h.appendChild(document.createElement('a'))
+ a.classList.add("mothball")
+ a.textContent = "📦"
+ a.href = this.server.URL(`mothballer/${cat}.mb`)
+ a.title = "Download a compiled puzzle for this category"
+ }
+
+ // List out puzzles in this category
+ let l = pdiv.appendChild(document.createElement("ul"))
+ for (let puzzle of this.state.Puzzles(cat)) {
+ let i = l.appendChild(document.createElement("li"))
+
+ let url = new URL("puzzle.html", window.location)
+ url.hash = `${puzzle.Category}:${puzzle.Points}`
+ let a = i.appendChild(document.createElement("a"))
+ a.textContent = puzzle.Points
+ a.href = url
+ a.target = "_blank"
+ }
+
+ if (!this.state.HasUnsolved(cat)) {
+ l.appendChild(document.createElement("li")).textContent = "✿"
+ }
+
+ element.appendChild(pdiv)
+ }
+ }
+}
+
+function init() {
+ window.app = {
+ server: new App()
+ }
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init)
+} else {
+ init()
+}
+
\ No newline at end of file
diff --git a/theme/logout.html b/theme/logout.html
deleted file mode 100644
index 14f3caf..0000000
--- a/theme/logout.html
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
MOTH
-
-
-
-
-
-
MOTH
-
- Okay, you've been logged out.
-
-
-
-
diff --git a/theme/moment.min.js b/theme/moment.min.js
deleted file mode 100644
index 5787a40..0000000
--- a/theme/moment.min.js
+++ /dev/null
@@ -1 +0,0 @@
-!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.moment=t()}(this,function(){"use strict";var e,i;function c(){return e.apply(null,arguments)}function o(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function u(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function l(e){return void 0===e}function h(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function d(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function f(e,t){var n,s=[];for(n=0;n
>>0,s=0;sSe(e)?(r=e+1,o-Se(e)):(r=e,o),{year:r,dayOfYear:a}}function Ie(e,t,n){var s,i,r=Ve(e.year(),t,n),a=Math.floor((e.dayOfYear()-r-1)/7)+1;return a<1?s=a+Ae(i=e.year()-1,t,n):a>Ae(e.year(),t,n)?(s=a-Ae(e.year(),t,n),i=e.year()+1):(i=e.year(),s=a),{week:s,year:i}}function Ae(e,t,n){var s=Ve(e,t,n),i=Ve(e+1,t,n);return(Se(e)-s+i)/7}I("w",["ww",2],"wo","week"),I("W",["WW",2],"Wo","isoWeek"),C("week","w"),C("isoWeek","W"),F("week",5),F("isoWeek",5),ue("w",B),ue("ww",B,z),ue("W",B),ue("WW",B,z),fe(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=D(e)});function je(e,t){return e.slice(t,7).concat(e.slice(0,t))}I("d",0,"do","day"),I("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),I("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),I("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),I("e",0,0,"weekday"),I("E",0,0,"isoWeekday"),C("day","d"),C("weekday","e"),C("isoWeekday","E"),F("day",11),F("weekday",11),F("isoWeekday",11),ue("d",B),ue("e",B),ue("E",B),ue("dd",function(e,t){return t.weekdaysMinRegex(e)}),ue("ddd",function(e,t){return t.weekdaysShortRegex(e)}),ue("dddd",function(e,t){return t.weekdaysRegex(e)}),fe(["dd","ddd","dddd"],function(e,t,n,s){var i=n._locale.weekdaysParse(e,s,n._strict);null!=i?t.d=i:g(n).invalidWeekday=e}),fe(["d","e","E"],function(e,t,n,s){t[s]=D(e)});var Ze="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_");var ze="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_");var $e="Su_Mo_Tu_We_Th_Fr_Sa".split("_");var qe=ae;var Je=ae;var Be=ae;function Qe(){function e(e,t){return t.length-e.length}var t,n,s,i,r,a=[],o=[],u=[],l=[];for(t=0;t<7;t++)n=y([2e3,1]).day(t),s=this.weekdaysMin(n,""),i=this.weekdaysShort(n,""),r=this.weekdays(n,""),a.push(s),o.push(i),u.push(r),l.push(s),l.push(i),l.push(r);for(a.sort(e),o.sort(e),u.sort(e),l.sort(e),t=0;t<7;t++)o[t]=he(o[t]),u[t]=he(u[t]),l[t]=he(l[t]);this._weekdaysRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+a.join("|")+")","i")}function Xe(){return this.hours()%12||12}function Ke(e,t){I(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function et(e,t){return t._meridiemParse}I("H",["HH",2],0,"hour"),I("h",["hh",2],0,Xe),I("k",["kk",2],0,function(){return this.hours()||24}),I("hmm",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)}),I("hmmss",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)+L(this.seconds(),2)}),I("Hmm",0,0,function(){return""+this.hours()+L(this.minutes(),2)}),I("Hmmss",0,0,function(){return""+this.hours()+L(this.minutes(),2)+L(this.seconds(),2)}),Ke("a",!0),Ke("A",!1),C("hour","h"),F("hour",13),ue("a",et),ue("A",et),ue("H",B),ue("h",B),ue("k",B),ue("HH",B,z),ue("hh",B,z),ue("kk",B,z),ue("hmm",Q),ue("hmmss",X),ue("Hmm",Q),ue("Hmmss",X),ce(["H","HH"],ge),ce(["k","kk"],function(e,t,n){var s=D(e);t[ge]=24===s?0:s}),ce(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),ce(["h","hh"],function(e,t,n){t[ge]=D(e),g(n).bigHour=!0}),ce("hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s)),g(n).bigHour=!0}),ce("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i)),g(n).bigHour=!0}),ce("Hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s))}),ce("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i))});var tt,nt=Te("Hours",!0),st={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Ce,monthsShort:He,week:{dow:0,doy:6},weekdays:Ze,weekdaysMin:$e,weekdaysShort:ze,meridiemParse:/[ap]\.?m?\.?/i},it={},rt={};function at(e){return e?e.toLowerCase().replace("_","-"):e}function ot(e){var t=null;if(!it[e]&&"undefined"!=typeof module&&module&&module.exports)try{t=tt._abbr,require("./locale/"+e),ut(t)}catch(e){}return it[e]}function ut(e,t){var n;return e&&((n=l(t)?ht(e):lt(e,t))?tt=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),tt._abbr}function lt(e,t){if(null===t)return delete it[e],null;var n,s=st;if(t.abbr=e,null!=it[e])T("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=it[e]._config;else if(null!=t.parentLocale)if(null!=it[t.parentLocale])s=it[t.parentLocale]._config;else{if(null==(n=ot(t.parentLocale)))return rt[t.parentLocale]||(rt[t.parentLocale]=[]),rt[t.parentLocale].push({name:e,config:t}),null;s=n._config}return it[e]=new P(x(s,t)),rt[e]&&rt[e].forEach(function(e){lt(e.name,e.config)}),ut(e),it[e]}function ht(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return tt;if(!o(e)){if(t=ot(e))return t;e=[e]}return function(e){for(var t,n,s,i,r=0;r=t&&a(i,n,!0)>=t-1)break;t--}r++}return tt}(e)}function dt(e){var t,n=e._a;return n&&-2===g(e).overflow&&(t=n[_e]<0||11Pe(n[me],n[_e])?ye:n[ge]<0||24Ae(n,r,a)?g(e)._overflowWeeks=!0:null!=u?g(e)._overflowWeekday=!0:(o=Ee(n,s,i,r,a),e._a[me]=o.year,e._dayOfYear=o.dayOfYear)}(e),null!=e._dayOfYear&&(r=ct(e._a[me],s[me]),(e._dayOfYear>Se(r)||0===e._dayOfYear)&&(g(e)._overflowDayOfYear=!0),n=Ge(r,0,e._dayOfYear),e._a[_e]=n.getUTCMonth(),e._a[ye]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=s[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[ge]&&0===e._a[ve]&&0===e._a[pe]&&0===e._a[we]&&(e._nextDay=!0,e._a[ge]=0),e._d=(e._useUTC?Ge:function(e,t,n,s,i,r,a){var o;return e<100&&0<=e?(o=new Date(e+400,t,n,s,i,r,a),isFinite(o.getFullYear())&&o.setFullYear(e)):o=new Date(e,t,n,s,i,r,a),o}).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[ge]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(g(e).weekdayMismatch=!0)}}var mt=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,_t=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,yt=/Z|[+-]\d\d(?::?\d\d)?/,gt=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],vt=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],pt=/^\/?Date\((\-?\d+)/i;function wt(e){var t,n,s,i,r,a,o=e._i,u=mt.exec(o)||_t.exec(o);if(u){for(g(e).iso=!0,t=0,n=gt.length;tn.valueOf():n.valueOf()this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},mn.isLocal=function(){return!!this.isValid()&&!this._isUTC},mn.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},mn.isUtc=Et,mn.isUTC=Et,mn.zoneAbbr=function(){return this._isUTC?"UTC":""},mn.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},mn.dates=n("dates accessor is deprecated. Use date instead.",un),mn.months=n("months accessor is deprecated. Use month instead",Ue),mn.years=n("years accessor is deprecated. Use year instead",Oe),mn.zone=n("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?("string"!=typeof e&&(e=-e),this.utcOffset(e,t),this):-this.utcOffset()}),mn.isDSTShifted=n("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!l(this._isDSTShifted))return this._isDSTShifted;var e={};if(w(e,this),(e=Ot(e))._a){var t=e._isUTC?y(e._a):bt(e._a);this._isDSTShifted=this.isValid()&&0 { p.remove() },
- timeout
- )
-}
-
-function renderNotices(obj) {
- let ne = document.getElementById("notices")
- if (ne) {
- ne.innerHTML = obj
- }
-}
-
-function renderPuzzles(obj) {
- let puzzlesElement = document.createElement('div')
-
- document.getElementById("login").style.display = "none"
-
- // Create a sorted list of category names
- let cats = Object.keys(obj)
- cats.sort()
- if (cats.length == 0) {
- toast("No categories to serve!")
- }
- for (let cat of cats) {
- if (cat.startsWith("__")) {
- // Skip metadata
- continue
- }
- let puzzles = obj[cat]
-
- let pdiv = document.createElement('div')
- pdiv.className = 'category'
-
- let h = document.createElement('h2')
- pdiv.appendChild(h)
- h.textContent = cat
-
- // Extras if we're running a devel server
- if (devel) {
- let a = document.createElement('a')
- h.insertBefore(a, h.firstChild)
- a.textContent = "⬇️"
- a.href = "mothballer/" + cat + ".mb"
- a.classList.add("mothball")
- a.title = "Download a compiled puzzle for this category"
- }
-
- // List out puzzles in this category
- let l = document.createElement('ul')
- pdiv.appendChild(l)
- for (let puzzle of puzzles) {
- let points = puzzle
- let id = null
-
- if (Array.isArray(puzzle)) {
- points = puzzle[0]
- id = puzzle[1]
- }
-
- let i = document.createElement('li')
- l.appendChild(i)
- i.textContent = " "
-
- if (points === 0) {
- // Sentry: there are no more puzzles in this category
- i.textContent = "✿"
- } else {
- let a = document.createElement('a')
- i.appendChild(a)
- a.textContent = points
- let url = new URL("puzzle.html", window.location)
- url.searchParams.set("cat", cat)
- url.searchParams.set("points", points)
- if (id) { url.searchParams.set("pid", id) }
- a.href = url.toString()
- }
- }
-
- puzzlesElement.appendChild(pdiv)
- }
-
- // Drop that thing in
- let container = document.getElementById("puzzles")
- while (container.firstChild) {
- container.firstChild.remove()
- }
- container.appendChild(puzzlesElement)
-}
-
-function renderState(obj) {
- window.state = obj
- devel = obj.Config.Devel
- if (devel) {
- let params = new URLSearchParams(window.location.search)
- sessionStorage.id = "1"
- renderPuzzles(obj.Puzzles)
- } else if (Object.keys(obj.Puzzles).length > 0) {
- renderPuzzles(obj.Puzzles)
- }
- renderNotices(obj.Messages)
-}
-
-function heartbeat() {
- let teamId = sessionStorage.id || ""
- let url = new URL("state", window.location)
- url.searchParams.set("id", teamId)
-
- let fd = new FormData()
- fd.append("id", teamId)
- fetch(url)
- .then(resp => {
- if (resp.ok) {
- resp.json()
- .then(renderState)
- .catch(err => {
- toast("Error fetching recent state. I'll try again in a moment.")
- console.log(err)
- })
- }
- })
- .catch(err => {
- toast("Error fetching recent state. I'll try again in a moment.")
- console.log(err)
- })
-}
-
-function showPuzzles() {
- let spinner = document.createElement("span")
- spinner.classList.add("spinner")
-
- document.getElementById("login").style.display = "none"
- document.getElementById("puzzles").appendChild(spinner)
-}
-
-function login(e) {
- e.preventDefault()
- let name = document.querySelector("[name=name]").value
- let teamId = document.querySelector("[name=id]").value
-
- fetch("register", {
- method: "POST",
- body: new FormData(e.target),
- })
- .then(resp => {
- if (resp.ok) {
- resp.json()
- .then(obj => {
- if ((obj.status == "success") || (obj.data.short == "Already registered")) {
- toast("Logged in")
- sessionStorage.id = teamId
- showPuzzles()
- heartbeat()
- } else {
- toast(obj.data.description)
- }
- })
- .catch(err => {
- toast("Oops, the server has lost its mind. You probably need to tell someone so they can fix it.")
- console.log(err, resp)
- })
- } else {
- toast("Oops, something's wrong with the server. Try again in a few seconds.")
- console.log(resp)
- }
- })
- .catch(err => {
- toast("Oops, something went wrong. Try again in a few seconds.")
- console.log(err)
- })
-}
-
-function init() {
- heartbeat()
- setInterval(e => heartbeat(), 40000)
-
- document.getElementById("login").addEventListener("submit", login)
-}
-
-if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", init)
-} else {
- init()
-}
-
diff --git a/theme/moth.mjs b/theme/moth.mjs
index 13acc7d..11b9181 100644
--- a/theme/moth.mjs
+++ b/theme/moth.mjs
@@ -208,6 +208,19 @@ class Puzzle {
}
return false
}
+
+ /**
+ * Submit a proposed answer for points.
+ *
+ * The returned promise will fail if anything goes wrong, including the
+ * proposed answer being rejected.
+ *
+ * @param {String} proposed Answer to submit
+ * @returns {Promise.} Success message
+ */
+ SubmitAnswer(proposed) {
+ return this.server.SubmitAnswer(this.Category, this.Points, proposed)
+ }
}
/**
@@ -228,23 +241,27 @@ class State {
/** Configuration */
this.Config = {
- /** Is the server in debug mode?
+ /** Is the server in development mode?
* @type {Boolean}
*/
- Debug: obj.Config.Debug,
+ Devel: obj.Config.Devel,
}
+
/** Global messages, in HTML
* @type {String}
*/
this.Messages = obj.Messages
+
/** Map from Team ID to Team Name
* @type {Object.}
*/
this.TeamNames = obj.TeamNames
+
/** Map from category name to puzzle point values
* @type {Object.}
*/
this.PointsByCategory = obj.Puzzles
+
/** Log of points awarded
* @type {Award[]}
*/
@@ -278,6 +295,15 @@ class State {
return !this.PointsByCategory[category].includes(0)
}
+ /**
+ * Is the server in development mode?
+ *
+ * @returns {Boolean}
+ */
+ DevelopmentMode() {
+ return this.Config && this.Config.Devel
+ }
+
/**
* Return all open puzzles.
*
@@ -313,10 +339,16 @@ class State {
* and will send a Team ID with every request, if it can find one.
*/
class Server {
+ /**
+ * @param {String | URL} baseUrl Base URL to server, for constructing API URLs
+ */
constructor(baseUrl) {
+ if (!baseUrl) {
+ throw("Must provide baseURL")
+ }
this.baseUrl = new URL(baseUrl, location)
- this.teameIdKey = this.baseUrl.toString() + " teamID"
- this.teamId = localStorage[this.teameIdKey]
+ this.teamIdKey = this.baseUrl.toString() + " teamID"
+ this.TeamId = localStorage[this.teamIdKey]
}
/**
@@ -326,21 +358,29 @@ class Server {
* this function throws an error.
*
* This always sends teamId.
- * If body is set, POST will be used instead of GET
+ * If args is set, POST will be used instead of GET
*
* @param {String} path Path to API endpoint
- * @param {Object.} body Key/Values to send in POST data
+ * @param {Object.} args Key/Values to send in POST data
* @returns {Promise.} Response
*/
- fetch(path, body) {
+ fetch(path, args) {
let url = new URL(path, this.baseUrl)
- if (this.teamId & (!(body && body.id))) {
- url.searchParams.set("id", this.teamId)
+ if (this.TeamId & (!(args && args.id))) {
+ url.searchParams.set("id", this.TeamId)
}
- return fetch(url, {
- method: body?"POST":"GET",
- body,
- })
+
+ if (args) {
+ let formData = new FormData()
+ for (let k in args) {
+ formData.set(k, args[k])
+ }
+ return fetch(url, {
+ method: "POST",
+ body: formData,
+ })
+ }
+ return fetch(url)
}
/**
@@ -356,7 +396,7 @@ class Server {
switch (obj.status) {
case "success":
return obj.data
- case "failure":
+ case "fail":
throw new Error(obj.data.description || obj.data.short || obj.data)
case "error":
throw new Error(obj.message)
@@ -365,20 +405,38 @@ class Server {
}
}
+ /**
+ * Make a new URL for the given resource.
+ *
+ * @returns {URL}
+ */
+ URL(url) {
+ return new URL(url, this.baseUrl)
+ }
+
+ /**
+ * Are we logged in to the server?
+ *
+ * @returns {Boolean}
+ */
+ LoggedIn() {
+ return this.TeamId ? true : false
+ }
+
/**
* Forget about any previous Team ID.
*
* This is equivalent to logging out.
*/
Reset() {
- localStorage.removeItem(this.teameIdKey)
- this.teamId = null
+ localStorage.removeItem(this.teamIdKey)
+ this.TeamId = null
}
/**
* Fetch current contest state.
*
- * @returns {State}
+ * @returns {Promise.}
*/
async GetState() {
let resp = await this.fetch("/state")
@@ -387,37 +445,41 @@ class Server {
}
/**
- * Register a team name with a team ID.
- *
- * This is similar to, but not exactly the same as, logging in.
- * See MOTH documentation for details.
- *
+ * Log in to a team.
+ *
+ * This calls the server's registration endpoint; if the call succeds, or
+ * fails with "team already exists", the login is returned as successful.
+ *
* @param {String} teamId
* @param {String} teamName
* @returns {Promise.} Success message from server
*/
- async Register(teamId, teamName) {
- let data = await this.call("/login", {id: teamId, name: teamName})
- this.teamId = teamId
- this.teamName = teamName
- localStorage[this.teameIdKey] = teamId
+ async Login(teamId, teamName) {
+ let data = await this.call("/register", {id: teamId, name: teamName})
+ this.TeamId = teamId
+ this.TeamName = teamName
+ localStorage[this.teamIdKey] = teamId
return data.description || data.short
}
/**
- * Submit a puzzle answer for points.
+ * Submit a proposed answer for points.
*
* The returned promise will fail if anything goes wrong, including the
- * answer being rejected.
+ * proposed answer being rejected.
*
* @param {String} category Category of puzzle
* @param {Number} points Point value of puzzle
- * @param {String} answer Answer to submit
- * @returns {Promise.} Was the answer accepted?
+ * @param {String} proposed Answer to submit
+ * @returns {Promise.} Success message
*/
- async SubmitAnswer(category, points, answer) {
- await this.call("/answer", {category, points, answer})
- return true
+ async SubmitAnswer(category, points, proposed) {
+ let data = await this.call("/answer", {
+ cat: category,
+ points,
+ answer: proposed,
+ })
+ return data.description || data.short
}
/**
diff --git a/theme/puzzle.html b/theme/puzzle.html
index a9f0963..a5e4232 100644
--- a/theme/puzzle.html
+++ b/theme/puzzle.html
@@ -10,29 +10,25 @@
+ [loading]
- [loading]
-
-
-
- Starting script...
-
-
+
+
+ Starting script...
+
+
+
Puzzle by [loading]
-
-
+
+
diff --git a/theme/puzzle.js b/theme/puzzle.js
deleted file mode 100644
index ab65341..0000000
--- a/theme/puzzle.js
+++ /dev/null
@@ -1,225 +0,0 @@
-// jshint asi:true
-
-// prettify adds classes to various types, returning an HTML string.
-function prettify(key, val) {
- switch (key) {
- case "Body":
- return '[HTML]'
- }
- return val
-}
-
-// devel_addin drops a bunch of development extensions into element e.
-// It will only modify stuff inside e.
-function devel_addin(e) {
- let h = e.appendChild(document.createElement("h2"))
- h.textContent = "Developer Output"
-
- let log = window.puzzle.Debug.Log || []
- if (log.length > 0) {
- e.appendChild(document.createElement("h3")).textContent = "Log"
- let le = e.appendChild(document.createElement("ul"))
- for (let entry of log) {
- le.appendChild(document.createElement("li")).textContent = entry
- }
- }
-
- e.appendChild(document.createElement("h3")).textContent = "Puzzle object"
-
- let hobj = JSON.stringify(window.puzzle, prettify, 2)
- let d = e.appendChild(document.createElement("pre"))
- d.classList.add("object")
- d.innerHTML = hobj
-
- e.appendChild(document.createElement("p")).textContent = "This debugging information will not be available to participants."
-}
-
-// Hash routine used in v3.4 and earlier
-function djb2hash(buf) {
- let h = 5381
- for (let c of (new TextEncoder()).encode(buf)) { // Encode as UTF-8 and read in each byte
- // JavaScript converts everything to a signed 32-bit integer when you do bitwise operations.
- // So we have to do "unsigned right shift" by zero to get it back to unsigned.
- h = (((h * 33) + c) & 0xffffffff) >>> 0
- }
- return h
-}
-
-// The routine used to hash answers in compiled puzzle packages
-async function sha256Hash(message) {
- const msgUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array
- const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message
- const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
- const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
- return hashHex;
-}
-
-// Is the provided answer possibly correct?
-async function checkAnswer(answer) {
- let answerHashes = []
- answerHashes.push(djb2hash(answer))
- answerHashes.push(await sha256Hash(answer))
-
- for (let hash of answerHashes) {
- for (let correctHash of window.puzzle.AnswerHashes) {
- if (hash == correctHash) {
- return true
- }
- }
- }
- return false
-}
-
-// Pop up a message
-function toast(message, timeout=5000) {
- let p = document.createElement("p")
-
- p.innerText = message
- document.getElementById("messages").appendChild(p)
- setTimeout(
- e => { p.remove() },
- timeout
- )
-}
-
-// When the user submits an answer
-function submit(e) {
- e.preventDefault()
- let data = new FormData(e.target)
-
- window.data = data
- fetch("answer", {
- method: "POST",
- body: data,
- })
- .then(resp => {
- if (resp.ok) {
- resp.json()
- .then(obj => {
- toast(obj.data.description)
- })
- } else {
- toast("Error submitting your answer. Try again in a few seconds.")
- console.log(resp)
- }
- })
- .catch(err => {
- toast("Error submitting your answer. Try again in a few seconds.")
- console.log(err)
- })
-}
-
-async function loadPuzzle(categoryName, points, puzzleId) {
- let puzzle = document.getElementById("puzzle")
- let base = "content/" + categoryName + "/" + puzzleId + "/"
-
- let resp = await fetch(base + "puzzle.json")
- if (! resp.ok) {
- console.log(resp)
- let err = await resp.text()
- Array.from(puzzle.childNodes).map(e => e.remove())
- p = puzzle.appendChild(document.createElement("p"))
- p.classList.add("Error")
- p.textContent = err
- return
- }
-
- // Make the whole puzzle available
- window.puzzle = await resp.json()
-
- // Populate authors
- document.getElementById("authors").textContent = window.puzzle.Authors.join(", ")
-
- // If answers are provided, this is the devel server
- if (window.puzzle.Answers.length > 0) {
- devel_addin(document.getElementById("devel"))
- }
-
- // Load scripts
- for (let script of (window.puzzle.Scripts || [])) {
- let st = document.createElement("script")
- document.head.appendChild(st)
- st.src = base + script
- }
-
- // List associated files
- for (let fn of (window.puzzle.Attachments || [])) {
- let li = document.createElement("li")
- let a = document.createElement("a")
- a.href = base + fn
- a.innerText = fn
- li.appendChild(a)
- document.getElementById("files").appendChild(li)
- }
-
- // Prefix `base` to relative URLs in the puzzle body
- let doc = new DOMParser().parseFromString(window.puzzle.Body, "text/html")
- for (let se of doc.querySelectorAll("[src],[href]")) {
- se.outerHTML = se.outerHTML.replace(/(src|href)="([^/]+)"/i, "$1=\"" + base + "$2\"")
- }
-
- // If a validation pattern was provided, set that
- if (window.puzzle.AnswerPattern) {
- document.querySelector("#answer").pattern = window.puzzle.AnswerPattern
- }
-
- // Replace puzzle children with what's in `doc`
- Array.from(puzzle.childNodes).map(e => e.remove())
- Array.from(doc.body.childNodes).map(e => puzzle.appendChild(e))
-
- document.title = categoryName + " " + points
- document.querySelector("body > h1").innerText = document.title
- document.querySelector("input[name=cat]").value = categoryName
- document.querySelector("input[name=points]").value = points
-}
-
-// Check to see if the answer might be correct
-// This might be better done with the "constraint validation API"
-// https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation#Validating_forms_using_JavaScript
-function answerCheck(e) {
- let answer = e.target.value
- let ok = document.querySelector("#answer_ok")
-
- // You have to provide someplace to put the check
- if (! ok) {
- return
- }
-
- checkAnswer(answer)
- .then (correct => {
- if (correct) {
- ok.textContent = "⭕"
- ok.title = "Possibly correct"
- } else {
- ok.textContent = "❌"
- ok.title = "Definitely not correct"
- }
- })
-}
-
-function init() {
- let params = new URLSearchParams(window.location.search)
- let categoryName = params.get("cat")
- let points = params.get("points")
- let puzzleId = params.get("pid")
-
- if (categoryName && points) {
- loadPuzzle(categoryName, points, puzzleId || points)
- }
-
- let teamId = sessionStorage.getItem("id")
- if (teamId) {
- document.querySelector("input[name=id]").value = teamId
- }
-
- if (document.querySelector("#answer")) {
- document.querySelector("#answer").addEventListener("input", answerCheck)
- }
- document.querySelector("form").addEventListener("submit", submit)
-}
-
-if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", init)
-} else {
- init()
-}
diff --git a/theme/puzzle.mjs b/theme/puzzle.mjs
index 5d8d86c..7d845ad 100644
--- a/theme/puzzle.mjs
+++ b/theme/puzzle.mjs
@@ -1,4 +1,10 @@
+/**
+ * Functionality for puzzle.html (Puzzle display / answer form)
+ */
import * as moth from "./moth.mjs"
+import * as common from "./common.mjs"
+
+const server = new moth.Server(".")
/**
* Handle a submit event on a form.
@@ -10,9 +16,22 @@ import * as moth from "./moth.mjs"
*
* @param {Event} event
*/
-function formSubmitHandler(event) {
+async function formSubmitHandler(event) {
event.preventDefault()
- console.log(event)
+ let data = new FormData(event.target)
+ let proposed = data.get("answer")
+ let message
+
+ console.group("Submit answer")
+ console.info(`Proposed answer: ${proposed}`)
+ try {
+ message = await window.app.puzzle.SubmitAnswer(proposed)
+ }
+ catch (err) {
+ common.Toast(err)
+ }
+ common.Toast(message)
+ console.groupEnd("Submit answer")
}
/**
@@ -69,12 +88,40 @@ function error(error) {
*
* @param {String} s
*/
-function setanswer(s) {
+function SetAnswer(s) {
let e = document.querySelector("#answer")
e.value = s
e.dispatchEvent(new Event("input"))
}
+function writeObject(e, obj) {
+ let keys = Object.keys(obj)
+ keys.sort()
+ for (let key of keys) {
+ let val = obj[key]
+ if ((key === "Body") || (!val) || (val.length === 0)) {
+ continue
+ }
+
+ let d = e.appendChild(document.createElement("dt"))
+ d.textContent = key
+
+ let t = e.appendChild(document.createElement("dd"))
+ if (Array.isArray(val)) {
+ let vi = t.appendChild(document.createElement("ul"))
+ vi.multiple = true
+ for (let a of val) {
+ let opt = vi.appendChild(document.createElement("li"))
+ opt.textContent = a
+ }
+ } else if (typeof(val) === "object") {
+ writeObject(t, val)
+ } else {
+ t.textContent = val
+ }
+ }
+}
+
/**
* Load the given puzzle.
*
@@ -93,16 +140,17 @@ async function loadPuzzle(category, points) {
}
}
- let server = new moth.Server()
let puzzle = server.GetPuzzle(category, points)
console.time("Populate")
await puzzle.Populate()
console.timeEnd("Populate")
+ console.info("Tweaking HTML...")
let title = `${category} ${points}`
document.querySelector("title").textContent = title
document.querySelector("#title").textContent = title
document.querySelector("#authors").textContent = puzzle.Authors.join(", ")
+ document.querySelector("#answer").pattern = window.puzzle.AnswerPattern
puzzleElement().innerHTML = puzzle.Body
console.info("Adding attached scripts...")
@@ -110,7 +158,7 @@ async function loadPuzzle(category, points) {
let st = document.createElement("script")
document.head.appendChild(st)
st.src = new URL(script, contentBase)
- }
+ }
console.info("Listing attached files...")
for (let fn of (puzzle.Attachments || [])) {
@@ -122,6 +170,16 @@ async function loadPuzzle(category, points) {
document.getElementById("files").appendChild(li)
}
+
+ console.info("Filling debug information...")
+ for (let e of document.querySelectorAll(".debug")) {
+ if (puzzle.Answers.length > 0) {
+ writeObject(e, puzzle)
+ } else {
+ e.classList.add("hidden")
+ }
+ }
+
let baseElement = document.head.appendChild(document.createElement("base"))
baseElement.href = contentBase
@@ -129,11 +187,13 @@ async function loadPuzzle(category, points) {
console.info("window.app.puzzle =", window.app.puzzle)
console.groupEnd()
+
+ return puzzle
}
-function init() {
+async function init() {
window.app = {}
- window.setanswer = setanswer
+ window.setanswer = (str => SetAnswer(str))
for (let form of document.querySelectorAll("form.answer")) {
form.addEventListener("submit", formSubmitHandler)
@@ -158,12 +218,7 @@ function init() {
return
}
- loadPuzzle(category, points)
- .catch(err => error(err))
+ window.app.puzzle = await loadPuzzle(category, points)
}
-if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", init)
-} else {
- init()
-}
+common.WhenDOMLoaded(init)
diff --git a/theme/scoreboard.css b/theme/scoreboard.css
new file mode 100644
index 0000000..470e72d
--- /dev/null
+++ b/theme/scoreboard.css
@@ -0,0 +1,91 @@
+/* GHC displays: 1024x1820 */
+@media screen and (max-aspect-ratio: 4/5) and (min-height: 1600px) {
+ html {
+ font-size: 20pt;
+ }
+}
+
+#chart {
+ background-color: rgba(0, 0, 0, 0.8);
+}
+.logo {
+ text-align: center;
+ background-color: rgba(255, 255, 255, 0.2);
+ font-family: Montserrat, sans-serif;
+ font-weight: 500;
+ border-radius: 10px;
+ font-size: 1.2em;
+}
+.cyber {
+ color: black;
+}
+.fire {
+ color: #d94a1f;
+}
+.announcement.floating {
+ position: fixed;
+ bottom: 0;
+ width: 100hw;
+ max-width: inherit;
+}
+.announcement {
+ background-color: rgba(255,255,255,0.5);
+ color: black;
+ padding: 0.25em;
+ border-radius: 5px;
+ max-width: 20em;
+ text-align: center;
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-around;
+ font-size: 1.3em;
+ flex-wrap: wrap;
+}
+.announcement div {
+ margin: 1em;
+ max-width: 45vw;
+ text-align: center;
+
+}
+.qrcode {
+ width: 30vw;
+}
+.examples {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+}
+.examples > div {
+ margin: 0.5em;
+ max-width: 40%;
+}
+
+#rankings {
+ width: 100%;
+ position: relative;
+ background-color: rgba(0, 0, 0, 0.8);
+}
+
+#rankings span {
+ font-size: 75%;
+ display: inline-block;
+ overflow: hidden;
+ height: 1.7em;
+}
+#rankings span.teamname {
+ font-size: inherit;
+ color: white;
+ text-shadow: 0 0 3px black;
+ opacity: 0.8;
+ position: absolute;
+ right: 0.2em;
+}
+#rankings div * {white-space: nowrap;}
+.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;}
+.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;}
+.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;}
+.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;}
+.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;}
+.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;}
+.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;}
+.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;}
diff --git a/theme/scoreboard.html b/theme/scoreboard.html
index ed0c339..8b1f787 100644
--- a/theme/scoreboard.html
+++ b/theme/scoreboard.html
@@ -3,22 +3,17 @@
Scoreboard
+
-
-
-
+
+
+
+
-
-