mirror of https://github.com/dirtbags/moth.git
Some twiddling to prepare for a scoreboard update
This commit is contained in:
parent
c0761933a9
commit
c72d13af32
|
@ -39,8 +39,8 @@ class App {
|
||||||
/**
|
/**
|
||||||
* Attempt to log in to the server.
|
* Attempt to log in to the server.
|
||||||
*
|
*
|
||||||
* @param {String} teamID
|
* @param {string} teamID
|
||||||
* @param {String} teamName
|
* @param {string} teamName
|
||||||
*/
|
*/
|
||||||
async Login(teamID, teamName) {
|
async Login(teamID, teamName) {
|
||||||
try {
|
try {
|
||||||
|
@ -114,7 +114,7 @@ class App {
|
||||||
/**
|
/**
|
||||||
* Render a login box.
|
* Render a login box.
|
||||||
*
|
*
|
||||||
* This just toggles visibility, there's nothing dynamic in a login box.
|
* Just toggles visibility, there's nothing dynamic in a login box.
|
||||||
*/
|
*/
|
||||||
renderLogin(element, visible) {
|
renderLogin(element, visible) {
|
||||||
element.classList.toggle("hidden", !visible)
|
element.classList.toggle("hidden", !visible)
|
||||||
|
@ -123,7 +123,7 @@ class App {
|
||||||
/**
|
/**
|
||||||
* Render a puzzles box.
|
* Render a puzzles box.
|
||||||
*
|
*
|
||||||
* This updates the list of open puzzles, and adds mothball download links
|
* Displays the list of open puzzles, and adds mothball download links
|
||||||
* if the server is in development mode.
|
* if the server is in development mode.
|
||||||
*/
|
*/
|
||||||
renderPuzzles(element, visible) {
|
renderPuzzles(element, visible) {
|
||||||
|
@ -177,9 +177,4 @@ function init() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
common.WhenDOMLoaded(init)
|
||||||
document.addEventListener("DOMContentLoaded", init)
|
|
||||||
} else {
|
|
||||||
init()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
146
theme/moth.mjs
146
theme/moth.mjs
|
@ -7,8 +7,8 @@ class Hash {
|
||||||
*
|
*
|
||||||
* Used until MOTH v3.5
|
* Used until MOTH v3.5
|
||||||
*
|
*
|
||||||
* @param {String} buf Input
|
* @param {string} buf Input
|
||||||
* @returns {Number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
static djb2(buf) {
|
static djb2(buf) {
|
||||||
let h = 5381
|
let h = 5381
|
||||||
|
@ -23,8 +23,8 @@ class Hash {
|
||||||
/**
|
/**
|
||||||
* Dan Bernstein hash with xor improvement
|
* Dan Bernstein hash with xor improvement
|
||||||
*
|
*
|
||||||
* @param {String} buf Input
|
* @param {string} buf Input
|
||||||
* @returns {Number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
static djb2xor(buf) {
|
static djb2xor(buf) {
|
||||||
let h = 5381
|
let h = 5381
|
||||||
|
@ -39,8 +39,8 @@ class Hash {
|
||||||
*
|
*
|
||||||
* Used until MOTH v4.5
|
* Used until MOTH v4.5
|
||||||
*
|
*
|
||||||
* @param {String} buf Input
|
* @param {string} buf Input
|
||||||
* @returns {Promise.<String>} hex-encoded digest
|
* @returns {Promise.<string>} hex-encoded digest
|
||||||
*/
|
*/
|
||||||
static async sha256(buf) {
|
static async sha256(buf) {
|
||||||
const msgUint8 = new TextEncoder().encode(buf)
|
const msgUint8 = new TextEncoder().encode(buf)
|
||||||
|
@ -52,8 +52,8 @@ class Hash {
|
||||||
/**
|
/**
|
||||||
* Hex-encode a byte array
|
* Hex-encode a byte array
|
||||||
*
|
*
|
||||||
* @param {Number[]} buf Byte array
|
* @param {number[]} buf Byte array
|
||||||
* @returns {String}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
static hexlify(buf) {
|
static hexlify(buf) {
|
||||||
return buf.map(b => b.toString(16).padStart(2, "0")).join("")
|
return buf.map(b => b.toString(16).padStart(2, "0")).join("")
|
||||||
|
@ -62,8 +62,8 @@ class Hash {
|
||||||
/**
|
/**
|
||||||
* Apply every hash to the input buffer.
|
* Apply every hash to the input buffer.
|
||||||
*
|
*
|
||||||
* @param {String} buf Input
|
* @param {string} buf Input
|
||||||
* @returns {Promise.<String[]>}
|
* @returns {Promise.<string[]>}
|
||||||
*/
|
*/
|
||||||
static async All(buf) {
|
static async All(buf) {
|
||||||
return [
|
return [
|
||||||
|
@ -80,19 +80,19 @@ class Hash {
|
||||||
class Award {
|
class Award {
|
||||||
constructor(when, teamid, category, points) {
|
constructor(when, teamid, category, points) {
|
||||||
/** Unix epoch timestamp for this award
|
/** Unix epoch timestamp for this award
|
||||||
* @type {Number}
|
* @type {number}
|
||||||
*/
|
*/
|
||||||
this.When = when
|
this.When = when
|
||||||
/** Team ID this award belongs to
|
/** Team ID this award belongs to
|
||||||
* @type {String}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
this.TeamID = teamid
|
this.TeamID = teamid
|
||||||
/** Puzzle category for this award
|
/** Puzzle category for this award
|
||||||
* @type {String}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
this.Category = category
|
this.Category = category
|
||||||
/** Points value of this award
|
/** Points value of this award
|
||||||
* @type {Number}
|
* @type {number}
|
||||||
*/
|
*/
|
||||||
this.Points = points
|
this.Points = points
|
||||||
}
|
}
|
||||||
|
@ -111,8 +111,8 @@ class Award {
|
||||||
class Puzzle {
|
class Puzzle {
|
||||||
/**
|
/**
|
||||||
* @param {Server} server
|
* @param {Server} server
|
||||||
* @param {String} category
|
* @param {string} category
|
||||||
* @param {Number} points
|
* @param {number} points
|
||||||
*/
|
*/
|
||||||
constructor (server, category, points) {
|
constructor (server, category, points) {
|
||||||
if (points < 1) {
|
if (points < 1) {
|
||||||
|
@ -173,7 +173,7 @@ class Puzzle {
|
||||||
/**
|
/**
|
||||||
* Get a resource associated with this puzzle.
|
* Get a resource associated with this puzzle.
|
||||||
*
|
*
|
||||||
* @param {String} filename Attachment/Script to retrieve
|
* @param {string} filename Attachment/Script to retrieve
|
||||||
* @returns {Promise.<Response>}
|
* @returns {Promise.<Response>}
|
||||||
*/
|
*/
|
||||||
Get(filename) {
|
Get(filename) {
|
||||||
|
@ -193,8 +193,8 @@ class Puzzle {
|
||||||
* you still have to pick through a lot of potentially correct answers when
|
* you still have to pick through a lot of potentially correct answers when
|
||||||
* it's done.
|
* it's done.
|
||||||
*
|
*
|
||||||
* @param {String} str User-submitted possible answer
|
* @param {string} str User-submitted possible answer
|
||||||
* @returns {Promise.<Boolean>}
|
* @returns {Promise.<boolean>}
|
||||||
*/
|
*/
|
||||||
async IsPossiblyCorrect(str) {
|
async IsPossiblyCorrect(str) {
|
||||||
let userAnswerHashes = await Hash.All(str)
|
let userAnswerHashes = await Hash.All(str)
|
||||||
|
@ -215,8 +215,8 @@ class Puzzle {
|
||||||
* The returned promise will fail if anything goes wrong, including the
|
* The returned promise will fail if anything goes wrong, including the
|
||||||
* proposed answer being rejected.
|
* proposed answer being rejected.
|
||||||
*
|
*
|
||||||
* @param {String} proposed Answer to submit
|
* @param {string} proposed Answer to submit
|
||||||
* @returns {Promise.<String>} Success message
|
* @returns {Promise.<string>} Success message
|
||||||
*/
|
*/
|
||||||
SubmitAnswer(proposed) {
|
SubmitAnswer(proposed) {
|
||||||
return this.server.SubmitAnswer(this.Category, this.Points, proposed)
|
return this.server.SubmitAnswer(this.Category, this.Points, proposed)
|
||||||
|
@ -242,23 +242,23 @@ class State {
|
||||||
/** Configuration */
|
/** Configuration */
|
||||||
this.Config = {
|
this.Config = {
|
||||||
/** Is the server in development mode?
|
/** Is the server in development mode?
|
||||||
* @type {Boolean}
|
* @type {boolean}
|
||||||
*/
|
*/
|
||||||
Devel: obj.Config.Devel,
|
Devel: obj.Config.Devel,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Global messages, in HTML
|
/** Global messages, in HTML
|
||||||
* @type {String}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
this.Messages = obj.Messages
|
this.Messages = obj.Messages
|
||||||
|
|
||||||
/** Map from Team ID to Team Name
|
/** Map from Team ID to Team Name
|
||||||
* @type {Object.<String,String>}
|
* @type {Object.<string,string>}
|
||||||
*/
|
*/
|
||||||
this.TeamNames = obj.TeamNames
|
this.TeamNames = obj.TeamNames
|
||||||
|
|
||||||
/** Map from category name to puzzle point values
|
/** Map from category name to puzzle point values
|
||||||
* @type {Object.<String,Number>}
|
* @type {Object.<string,number>}
|
||||||
*/
|
*/
|
||||||
this.PointsByCategory = obj.Puzzles
|
this.PointsByCategory = obj.Puzzles
|
||||||
|
|
||||||
|
@ -271,7 +271,7 @@ class State {
|
||||||
/**
|
/**
|
||||||
* Returns a sorted list of open category names
|
* Returns a sorted list of open category names
|
||||||
*
|
*
|
||||||
* @returns {String[]} List of categories
|
* @returns {string[]} List of categories
|
||||||
*/
|
*/
|
||||||
Categories() {
|
Categories() {
|
||||||
let ret = []
|
let ret = []
|
||||||
|
@ -288,8 +288,8 @@ class State {
|
||||||
* The server adds a puzzle with 0 points in every "solved" category,
|
* The server adds a puzzle with 0 points in every "solved" category,
|
||||||
* so this just checks whether there is a 0-point puzzle in the category's point list.
|
* so this just checks whether there is a 0-point puzzle in the category's point list.
|
||||||
*
|
*
|
||||||
* @param {String} category
|
* @param {string} category
|
||||||
* @returns {Boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
ContainsUnsolved(category) {
|
ContainsUnsolved(category) {
|
||||||
return !this.PointsByCategory[category].includes(0)
|
return !this.PointsByCategory[category].includes(0)
|
||||||
|
@ -298,7 +298,7 @@ class State {
|
||||||
/**
|
/**
|
||||||
* Is the server in development mode?
|
* Is the server in development mode?
|
||||||
*
|
*
|
||||||
* @returns {Boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
DevelopmentMode() {
|
DevelopmentMode() {
|
||||||
return this.Config && this.Config.Devel
|
return this.Config && this.Config.Devel
|
||||||
|
@ -310,7 +310,7 @@ class State {
|
||||||
* The returned list will be sorted by (category, points).
|
* The returned list will be sorted by (category, points).
|
||||||
* If not categories are given, all puzzles will be returned.
|
* If not categories are given, all puzzles will be returned.
|
||||||
*
|
*
|
||||||
* @param {String} categories Limit results to these categories
|
* @param {string} categories Limit results to these categories
|
||||||
* @returns {Puzzle[]}
|
* @returns {Puzzle[]}
|
||||||
*/
|
*/
|
||||||
Puzzles(...categories) {
|
Puzzles(...categories) {
|
||||||
|
@ -335,8 +335,8 @@ class State {
|
||||||
* Has this puzzle been solved by this team?
|
* Has this puzzle been solved by this team?
|
||||||
*
|
*
|
||||||
* @param {Puzzle} puzzle
|
* @param {Puzzle} puzzle
|
||||||
* @param {String} teamID Team to check, default the logged-in team
|
* @param {string} teamID Team to check, default the logged-in team
|
||||||
* @returns {Boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
IsSolved(puzzle, teamID="self") {
|
IsSolved(puzzle, teamID="self") {
|
||||||
for (let award of this.PointsLog) {
|
for (let award of this.PointsLog) {
|
||||||
|
@ -350,6 +350,52 @@ class State {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map from team ID to points.
|
||||||
|
*
|
||||||
|
* A special "max" property contains the highest number of points in this map.
|
||||||
|
*
|
||||||
|
* @typedef {Object.<string, number>} TeamPointsDict
|
||||||
|
* @property {Number} max Highest number of points
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map from category to PointsDict.
|
||||||
|
*
|
||||||
|
* @typedef {Object.<string, TeamPointsDict>} CategoryTeamPointsDict
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score snapshot.
|
||||||
|
*
|
||||||
|
* @typedef {Object} ScoreSnapshot
|
||||||
|
* @property {number} when Epoch time of this snapshot
|
||||||
|
* @property {CategoryTeamPointsDict} snapshot
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay scores.
|
||||||
|
*
|
||||||
|
* @yields {ScoreSnapshot} Snapshot at a point in time
|
||||||
|
*/
|
||||||
|
* ScoreHistory() {
|
||||||
|
/** @type {CategoryTeamPointsDict} */
|
||||||
|
let categoryTeamPoints = {}
|
||||||
|
for (let award of this.PointsLog) {
|
||||||
|
let teamPoints = (categoryTeamPoints[award.Category] ??= {})
|
||||||
|
let points = teamPoints[award.TeamID] || 0
|
||||||
|
let max = teamPoints.max || 0
|
||||||
|
|
||||||
|
points += award.Points
|
||||||
|
teamPoints[award.TeamID] = points
|
||||||
|
teamPoints.max = Math.max(points, max)
|
||||||
|
|
||||||
|
/** @type ScoreSnapshot */
|
||||||
|
let snapshot = {when: award.When, snapshot: categoryTeamPoints}
|
||||||
|
yield snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -360,7 +406,7 @@ class State {
|
||||||
*/
|
*/
|
||||||
class Server {
|
class Server {
|
||||||
/**
|
/**
|
||||||
* @param {String | URL} baseUrl Base URL to server, for constructing API URLs
|
* @param {string | URL} baseUrl Base URL to server, for constructing API URLs
|
||||||
*/
|
*/
|
||||||
constructor(baseUrl) {
|
constructor(baseUrl) {
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
|
@ -380,8 +426,8 @@ class Server {
|
||||||
* This always sends teamID.
|
* This always sends teamID.
|
||||||
* If args 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 {string} path Path to API endpoint
|
||||||
* @param {Object.<String,String>} args Key/Values to send in POST data
|
* @param {Object.<string,string>} args Key/Values to send in POST data
|
||||||
* @returns {Promise.<Response>} Response
|
* @returns {Promise.<Response>} Response
|
||||||
*/
|
*/
|
||||||
fetch(path, args={}) {
|
fetch(path, args={}) {
|
||||||
|
@ -400,8 +446,8 @@ class Server {
|
||||||
/**
|
/**
|
||||||
* Send a request to a JSend API endpoint.
|
* Send a request to a JSend API endpoint.
|
||||||
*
|
*
|
||||||
* @param {String} path Path to API endpoint
|
* @param {string} path Path to API endpoint
|
||||||
* @param {Object.<String,String>} args Key/Values to send in POST
|
* @param {Object.<string,string>} args Key/Values to send in POST
|
||||||
* @returns {Promise.<Object>} JSend Data
|
* @returns {Promise.<Object>} JSend Data
|
||||||
*/
|
*/
|
||||||
async call(path, args={}) {
|
async call(path, args={}) {
|
||||||
|
@ -434,7 +480,7 @@ class Server {
|
||||||
/**
|
/**
|
||||||
* Are we logged in to the server?
|
* Are we logged in to the server?
|
||||||
*
|
*
|
||||||
* @returns {Boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
LoggedIn() {
|
LoggedIn() {
|
||||||
return this.TeamID ? true : false
|
return this.TeamID ? true : false
|
||||||
|
@ -467,9 +513,9 @@ class Server {
|
||||||
* This calls the server's registration endpoint; if the call succeds, or
|
* This calls the server's registration endpoint; if the call succeds, or
|
||||||
* fails with "team already exists", the login is returned as successful.
|
* fails with "team already exists", the login is returned as successful.
|
||||||
*
|
*
|
||||||
* @param {String} teamID
|
* @param {string} teamID
|
||||||
* @param {String} teamName
|
* @param {string} teamName
|
||||||
* @returns {Promise.<String>} Success message from server
|
* @returns {Promise.<string>} Success message from server
|
||||||
*/
|
*/
|
||||||
async Login(teamID, teamName) {
|
async Login(teamID, teamName) {
|
||||||
let data = await this.call("/register", {id: teamID, name: teamName})
|
let data = await this.call("/register", {id: teamID, name: teamName})
|
||||||
|
@ -485,10 +531,10 @@ class Server {
|
||||||
* The returned promise will fail if anything goes wrong, including the
|
* The returned promise will fail if anything goes wrong, including the
|
||||||
* proposed answer being rejected.
|
* proposed answer being rejected.
|
||||||
*
|
*
|
||||||
* @param {String} category Category of puzzle
|
* @param {string} category Category of puzzle
|
||||||
* @param {Number} points Point value of puzzle
|
* @param {number} points Point value of puzzle
|
||||||
* @param {String} proposed Answer to submit
|
* @param {string} proposed Answer to submit
|
||||||
* @returns {Promise.<String>} Success message
|
* @returns {Promise.<string>} Success message
|
||||||
*/
|
*/
|
||||||
async SubmitAnswer(category, points, proposed) {
|
async SubmitAnswer(category, points, proposed) {
|
||||||
let data = await this.call("/answer", {
|
let data = await this.call("/answer", {
|
||||||
|
@ -502,9 +548,9 @@ class Server {
|
||||||
/**
|
/**
|
||||||
* Fetch a file associated with a puzzle.
|
* Fetch a file associated with a puzzle.
|
||||||
*
|
*
|
||||||
* @param {String} category Category of puzzle
|
* @param {string} category Category of puzzle
|
||||||
* @param {Number} points Point value of puzzle
|
* @param {number} points Point value of puzzle
|
||||||
* @param {String} filename
|
* @param {string} filename
|
||||||
* @returns {Promise.<Response>}
|
* @returns {Promise.<Response>}
|
||||||
*/
|
*/
|
||||||
GetContent(category, points, filename) {
|
GetContent(category, points, filename) {
|
||||||
|
@ -517,8 +563,8 @@ class Server {
|
||||||
* New Puzzle objects only know their category and point value.
|
* New Puzzle objects only know their category and point value.
|
||||||
* See docstrings on the Puzzle object for more information.
|
* See docstrings on the Puzzle object for more information.
|
||||||
*
|
*
|
||||||
* @param {String} category
|
* @param {string} category
|
||||||
* @param {Number} points
|
* @param {number} points
|
||||||
* @returns {Puzzle}
|
* @returns {Puzzle}
|
||||||
*/
|
*/
|
||||||
GetPuzzle(category, points) {
|
GetPuzzle(category, points) {
|
||||||
|
|
|
@ -9,7 +9,7 @@ const server = new moth.Server(".")
|
||||||
/**
|
/**
|
||||||
* Handle a submit event on a form.
|
* Handle a submit event on a form.
|
||||||
*
|
*
|
||||||
* This event will be called when the user submits the form,
|
* Called when the user submits the form,
|
||||||
* either by clicking a "submit" button,
|
* either by clicking a "submit" button,
|
||||||
* or by some other means provided by the browser,
|
* or by some other means provided by the browser,
|
||||||
* like hitting the Enter key.
|
* like hitting the Enter key.
|
||||||
|
@ -22,7 +22,7 @@ async function formSubmitHandler(event) {
|
||||||
let proposed = data.get("answer")
|
let proposed = data.get("answer")
|
||||||
let message
|
let message
|
||||||
|
|
||||||
console.group("Submit answer")
|
console.groupCollapsed("Submit answer")
|
||||||
console.info(`Proposed answer: ${proposed}`)
|
console.info(`Proposed answer: ${proposed}`)
|
||||||
try {
|
try {
|
||||||
message = await window.app.puzzle.SubmitAnswer(proposed)
|
message = await window.app.puzzle.SubmitAnswer(proposed)
|
||||||
|
@ -56,7 +56,7 @@ async function answerInputHandler(event) {
|
||||||
/**
|
/**
|
||||||
* Return the puzzle content element, possibly with everything cleared out of it.
|
* Return the puzzle content element, possibly with everything cleared out of it.
|
||||||
*
|
*
|
||||||
* @param {Boolean} clear Should the element be cleared of children? Default true.
|
* @param {boolean} clear Should the element be cleared of children? Default true.
|
||||||
* @returns {Element}
|
* @returns {Element}
|
||||||
*/
|
*/
|
||||||
function puzzleElement(clear=true) {
|
function puzzleElement(clear=true) {
|
||||||
|
@ -70,9 +70,10 @@ function puzzleElement(clear=true) {
|
||||||
/**
|
/**
|
||||||
* Display an error in the puzzle area, and also send it to the console.
|
* Display an error in the puzzle area, and also send it to the console.
|
||||||
*
|
*
|
||||||
* This makes it so the user can see a bit more about what the problem is.
|
* Errors are rendered in the puzzle area, so the user can see a bit more about
|
||||||
|
* what the problem is.
|
||||||
*
|
*
|
||||||
* @param {String} error
|
* @param {string} error
|
||||||
*/
|
*/
|
||||||
function error(error) {
|
function error(error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
@ -84,9 +85,9 @@ function error(error) {
|
||||||
/**
|
/**
|
||||||
* Set the answer and invoke input handlers.
|
* Set the answer and invoke input handlers.
|
||||||
*
|
*
|
||||||
* This makes sure the Circle Of Success gets updated.
|
* Makes sure the Circle Of Success gets updated.
|
||||||
*
|
*
|
||||||
* @param {String} s
|
* @param {string} s
|
||||||
*/
|
*/
|
||||||
function SetAnswer(s) {
|
function SetAnswer(s) {
|
||||||
let e = document.querySelector("#answer")
|
let e = document.querySelector("#answer")
|
||||||
|
@ -125,11 +126,11 @@ function writeObject(e, obj) {
|
||||||
/**
|
/**
|
||||||
* Load the given puzzle.
|
* Load the given puzzle.
|
||||||
*
|
*
|
||||||
* @param {String} category
|
* @param {string} category
|
||||||
* @param {Number} points
|
* @param {number} points
|
||||||
*/
|
*/
|
||||||
async function loadPuzzle(category, points) {
|
async function loadPuzzle(category, points) {
|
||||||
console.group("Loading puzzle:", category, points)
|
console.groupCollapsed("Loading puzzle:", category, points)
|
||||||
let contentBase = new URL(`content/${category}/${points}/`, location)
|
let contentBase = new URL(`content/${category}/${points}/`, location)
|
||||||
|
|
||||||
// Tell user we're loading
|
// Tell user we're loading
|
||||||
|
|
Loading…
Reference in New Issue