Neale Pickett
·
2024-04-17
index.mjs
1/**
2 * Functionality for index.html (Login / Puzzles list)
3 */
4import * as moth from "./moth.mjs"
5import * as common from "./common.mjs"
6
7class App {
8 constructor(basePath=".") {
9 this.config = {}
10
11 this.server = new moth.Server(basePath)
12
13 for (let form of document.querySelectorAll("form.login")) {
14 form.addEventListener("submit", event => this.handleLoginSubmit(event))
15 }
16 for (let e of document.querySelectorAll(".logout")) {
17 e.addEventListener("click", () => this.Logout())
18 }
19
20 common.StateUpdateChannel.addEventListener("message", () => {
21 // Give mothd time to catch up
22 setTimeout(() => this.UpdateState(), 1/2 * common.Second)
23 })
24
25 setInterval(() => this.UpdateState(), common.Minute/3)
26 setInterval(() => this.UpdateConfig(), common.Minute* 5)
27
28 this.UpdateConfig()
29 .finally(() => this.UpdateState())
30 }
31
32 handleLoginSubmit(event) {
33 event.preventDefault()
34 let f = new FormData(event.target)
35 this.Login(f.get("id"), f.get("name"))
36 }
37
38 /**
39 * Attempt to log in to the server.
40 *
41 * @param {string} teamID
42 * @param {string} teamName
43 */
44 async Login(teamID, teamName) {
45 try {
46 await this.server.Login(teamID, teamName)
47 common.Toast(`Logged in (team id = ${teamID})`)
48 this.UpdateState()
49 }
50 catch (error) {
51 common.Toast(error)
52 }
53 }
54
55 /**
56 * Log out of the server by clearing the saved Team ID.
57 */
58 async Logout() {
59 try {
60 this.server.Reset()
61 common.Toast("Logged out")
62 this.UpdateState()
63 }
64 catch (error) {
65 common.Toast(error)
66 }
67 }
68
69 /**
70 * Update app configuration.
71 *
72 * Configuration can be updated less frequently than state, to reduce server
73 * load, since configuration should (hopefully) change less frequently.
74 */
75 async UpdateConfig() {
76 this.config = await common.Config()
77
78 for (let e of document.querySelectorAll(".messages")) {
79 e.innerHTML = this.config.Messages || ""
80 }
81 }
82
83 /**
84 * Update the entire page.
85 *
86 * Fetch a new state, and rebuild all dynamic elements on this bage based on
87 * what's returned. If we're in development mode and not logged in, auto
88 * login too.
89 */
90 async UpdateState() {
91 this.state = await this.server.GetState()
92
93 // Update elements with data-track-solved
94 for (let e of document.querySelectorAll("[data-track-solved]")) {
95 // Only hide if data-track-solved is different than config.PuzzleList.TrackSolved
96 let tracking = this.config.PuzzleList?.TrackSolved || false
97 let displayIf = common.StringTruthy(e.dataset.trackSolved)
98 e.classList.toggle("hidden", tracking != displayIf)
99 }
100
101 for (let e of document.querySelectorAll(".login")) {
102 this.renderLogin(e, !this.server.LoggedIn())
103 }
104 for (let e of document.querySelectorAll(".puzzles")) {
105 this.renderPuzzles(e, this.server.LoggedIn())
106 }
107
108 if (this.state.DevelopmentMode() && !this.server.LoggedIn()) {
109 let teamID = Math.floor(Math.random() * 1000000).toString(16)
110 common.Toast("Automatically logging in to devel server")
111 console.info(`Logging in with generated Team ID: ${teamID}`)
112 return this.Login(teamID, `Team ${teamID}`)
113 }
114 }
115
116 /**
117 * Render a login box.
118 *
119 * Just toggles visibility, there's nothing dynamic in a login box.
120 */
121 renderLogin(element, visible) {
122 element.classList.toggle("hidden", !visible)
123 }
124
125 /**
126 * Render a puzzles box.
127 *
128 * Displays the list of open puzzles, and adds mothball download links
129 * if the server is in development mode.
130 */
131 renderPuzzles(element, visible) {
132 element.classList.toggle("hidden", !visible)
133 while (element.firstChild) element.firstChild.remove()
134 for (let cat of this.state.Categories()) {
135 let pdiv = element.appendChild(document.createElement("div"))
136 pdiv.classList.add("category")
137
138 let h = pdiv.appendChild(document.createElement("h2"))
139 h.textContent = cat
140
141 // Extras if we're running a devel server
142 if (this.state.DevelopmentMode()) {
143 let a = h.appendChild(document.createElement('a'))
144 a.classList.add("mothball")
145 a.textContent = "⬇️"
146 a.href = this.server.URL(`mothballer/${cat}.mb`)
147 a.title = "Download a compiled puzzle for this category"
148 }
149
150 // List out puzzles in this category
151 let l = pdiv.appendChild(document.createElement("ul"))
152 for (let puzzle of this.state.Puzzles(cat)) {
153 let i = l.appendChild(document.createElement("li"))
154
155 let url = new URL("puzzle.html", common.BaseURL)
156 url.hash = `${puzzle.Category}:${puzzle.Points}`
157 let a = i.appendChild(document.createElement("a"))
158 a.textContent = puzzle.Points
159 a.href = url
160 a.target = "_blank"
161
162 if (this.config.PuzzleList?.TrackSolved) {
163 a.classList.toggle("solved", this.state.IsSolved(puzzle))
164 }
165 if (this.config.PuzzleList?.Titles) {
166 this.loadTitle(puzzle, i)
167 }
168 }
169
170 if (!this.state.ContainsUnsolved(cat)) {
171 l.appendChild(document.createElement("li")).textContent = "✿"
172 }
173
174 element.appendChild(pdiv)
175 }
176 }
177
178 /**
179 * Asynchronously loads a puzzle, in order to populate the title.
180 *
181 * Calling this for every open puzzle will generate a lot of load on the server.
182 * If we decide we want this for a multi-participant server,
183 * we should implement some sort of cache.
184 *
185 * @param {Puzzle} puzzle
186 * @param {Element} element
187 */
188 async loadTitle(puzzle, element) {
189 await puzzle.Populate()
190 let title = puzzle.Extra.title
191 if (!title) {
192 return
193 }
194 element.classList.add("entitled")
195 for (let a of element.querySelectorAll("a")) {
196 a.textContent += `: ${title}`
197 }
198 }
199}
200
201function init() {
202 window.app = new App()
203}
204
205common.WhenDOMLoaded(init)