From 373a564547f8a65f901606cf60abf6e9c2e5376b Mon Sep 17 00:00:00 2001 From: Lanius Trolling Date: Sun, 11 Aug 2024 12:33:17 -0400 Subject: [PATCH] Refactor JS --- .../kotlin/info/mechyrdia/auth/ViewsLogin.kt | 5 +- .../kotlin/info/mechyrdia/auth/WebDav.kt | 5 +- .../info/mechyrdia/data/ViewComments.kt | 1 + .../info/mechyrdia/data/ViewsComment.kt | 14 +- .../kotlin/info/mechyrdia/lore/HttpUtils.kt | 6 + .../kotlin/info/mechyrdia/lore/ParserHtml.kt | 30 +- .../kotlin/info/mechyrdia/lore/ViewTpl.kt | 39 - .../kotlin/info/mechyrdia/lore/ViewsPrefs.kt | 2 +- .../kotlin/info/mechyrdia/robot/RobotApi.kt | 8 +- .../info/mechyrdia/robot/RobotService.kt | 31 +- .../kotlin/info/mechyrdia/robot/ViewsRobot.kt | 29 +- .../info/mechyrdia/route/ResourceTypes.kt | 6 +- src/jvmMain/resources/static/init.js | 1354 ++++++++++------- src/jvmMain/resources/static/style.css | 34 +- 14 files changed, 868 insertions(+), 696 deletions(-) diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt index e81e3cf..9fe6dec 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt @@ -5,6 +5,7 @@ import info.mechyrdia.Configuration import info.mechyrdia.data.* import info.mechyrdia.lore.page import info.mechyrdia.lore.redirectHref +import info.mechyrdia.lore.redirectHrefWithError import info.mechyrdia.lore.standardNavBar import info.mechyrdia.route.Root import info.mechyrdia.route.href @@ -110,10 +111,10 @@ suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, tokenId .token("mechyrdia_$nsToken") .shards(NationShard.NAME, NationShard.FLAG_URL) .executeSuspend() - ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "That nation does not exist.")))) + ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "That nation does not exist.") if (!result.isVerified) - redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "Checksum failed verification.")))) + redirectHrefWithError(Root.Auth.LoginPage(), error = "Checksum failed verification.") NationData(Id(result.id), result.name, result.flagUrl).also { NationData.Table.put(it) } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/auth/WebDav.kt b/src/jvmMain/kotlin/info/mechyrdia/auth/WebDav.kt index b1701c9..fbbdbbf 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/auth/WebDav.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/auth/WebDav.kt @@ -5,6 +5,7 @@ import info.mechyrdia.data.* import info.mechyrdia.lore.adminPage import info.mechyrdia.lore.dateTime import info.mechyrdia.lore.redirectHref +import info.mechyrdia.lore.redirectHrefWithError import info.mechyrdia.route.Root import info.mechyrdia.route.href import info.mechyrdia.route.installCsrfToken @@ -35,7 +36,7 @@ data class WebDavToken( suspend fun ApplicationCall.adminRequestWebDavToken(): HTML.() -> Unit { val nation = currentNation() - ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to request WebDAV tokens")))) + ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to request WebDAV tokens") val existingTokens = WebDavToken.Table .filter(Filters.eq(WebDavToken::holder.serialName, nation.id)) @@ -83,7 +84,7 @@ suspend fun ApplicationCall.adminRequestWebDavToken(): HTML.() -> Unit { suspend fun ApplicationCall.adminObtainWebDavToken(): HTML.() -> Unit { val nation = currentNation() - ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to generate WebDAV tokens")))) + ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to generate WebDAV tokens") val token = WebDavToken( holder = nation.id, diff --git a/src/jvmMain/kotlin/info/mechyrdia/data/ViewComments.kt b/src/jvmMain/kotlin/info/mechyrdia/data/ViewComments.kt index 484bad3..8916fbb 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/data/ViewComments.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/data/ViewComments.kt @@ -173,6 +173,7 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id Unit { } suspend fun ApplicationCall.newCommentRoute(pagePathParts: List, contents: String): Nothing { - val loggedInAs = currentNation() ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to write comments")))) + val loggedInAs = currentNation() ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to write comments") if (contents.isBlank()) - redirectHref(Root.LorePage(pagePathParts, root = Root(error = "Comments may not be blank"))) + redirectHrefWithError(Root.LorePage(pagePathParts), error = "Comments may not be blank") val now = Instant.now() val comment = Comment( @@ -97,20 +96,19 @@ suspend fun ApplicationCall.viewCommentRoute(commentId: Id): Nothing { throw NoSuchElementException("Shadowbanned comment") val pagePathParts = comment.submittedIn.split('/') - val errorMessage = attributes.getOrNull(ErrorMessageAttributeKey) - redirectHref(Root.LorePage(pagePathParts, root = Root(errorMessage)), hash = "comment-$commentId") + redirectHref(Root.LorePage(pagePathParts), hash = "comment-$commentId") } suspend fun ApplicationCall.editCommentRoute(commentId: Id, newContents: String): Nothing { val oldComment = Comment.Table.get(commentId)!! - val currNation = currentNation() ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root("You must be logged in to edit comments")))) + val currNation = currentNation() ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to edit comments") if (currNation.id != oldComment.submittedBy) throw ForbiddenException("Illegal attempt by ${currNation.id} to edit comment by ${oldComment.submittedBy}") if (newContents.isBlank()) - redirectHref(Root.Comments.ViewPage(oldComment.id, Root.Comments(Root("Comments may not be blank")))) + redirectHrefWithError(Root.Comments.ViewPage(oldComment.id), error = "Comments may not be blank") // Check for null edits, i.e. edits that don't change anything if (newContents == oldComment.contents) @@ -130,7 +128,7 @@ suspend fun ApplicationCall.editCommentRoute(commentId: Id, newContents } private suspend fun ApplicationCall.getCommentForDeletion(commentId: Id): Pair { - val currNation = currentNation() ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root("You must be logged in to delete comments")))) + val currNation = currentNation() ?: redirectHrefWithError(Root.Auth.LoginPage(), "You must be logged in to delete comments") val comment = Comment.Table.get(commentId)!! if (currNation.id != comment.submittedBy && currNation.id != OwnerNationId) diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt index 566dba7..1b46330 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/HttpUtils.kt @@ -1,5 +1,6 @@ package info.mechyrdia.lore +import info.mechyrdia.route.ErrorMessageCookieName import info.mechyrdia.route.href import io.ktor.server.application.* @@ -7,4 +8,9 @@ data class HttpRedirectException(val url: String, val permanent: Boolean) : Runt fun redirect(url: String, permanent: Boolean = false): Nothing = throw HttpRedirectException(url, permanent) +inline fun ApplicationCall.redirectHrefWithError(resource: T, error: String, hash: String? = null): Nothing { + response.cookies.append(ErrorMessageCookieName, error, secure = true, httpOnly = false, extensions = mapOf("SameSite" to "Lax")) + redirect(href(resource, hash), false) +} + inline fun ApplicationCall.redirectHref(resource: T, permanent: Boolean = false, hash: String? = null): Nothing = redirect(href(resource, hash), permanent) diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt index 7eefe4c..842efe9 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ParserHtml.kt @@ -5,7 +5,6 @@ import io.ktor.util.* import kotlinx.html.* import kotlinx.html.org.w3c.dom.events.Event import kotlinx.html.stream.createHTML -import kotlinx.serialization.json.JsonPrimitive import java.time.Instant import kotlin.text.toCharArray @@ -295,10 +294,9 @@ class HtmlHeaderLexerTag(val tagCreator: TagCreator, val anchor: (String) -> Str anchorId?.let { a { id = it } } tagCreator { + attributes["data-redirect-id"] = anchorHash +content } - - script { unsafe { +"window.checkRedirectTarget(\"$anchorHash\");" } } } } } @@ -448,7 +446,10 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) { }) } else { ({ - script { unsafe { +"window.appendImageThumb(\"/assets/images/$url\", \"$styleValue\");" } } + span(classes = "image-thumb") { + attributes["data-src"] = "/assets/images/$url" + attributes["data-style"] = styleValue + } }) } }), @@ -475,10 +476,12 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) { }) }), QUIZ(HtmlTextBodyLexerTag { _, _, content -> - val contentJson = JsonStorageCodec.parseToJsonElement(content) + val contentJson = JsonStorageCodec.parseToJsonElement(content).toString() ({ - script { unsafe { +"window.renderQuiz($contentJson);" } } + span(classes = "quiz") { + attributes["data-quiz"] = contentJson + } }) }), @@ -512,11 +515,14 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) { }), REDIRECT(HtmlTextBodyLexerTag { _, _, content -> val url = content.toInternalUrl() - val jsString = JsonPrimitive(url).toString() ({ - script { - unsafe { +"window.factbookRedirect($jsString);" } + p { + style = "font-weight:800" + +"Redirect to " + a(href = url, classes = "redirect-link") { + +url + } } }) }), @@ -641,10 +647,12 @@ enum class FactbookFormattingTag(val type: HtmlLexerTag) { } ), VOCAB(HtmlTextBodyLexerTag { _, _, content -> - val contentJson = JsonStorageCodec.parseToJsonElement(content) + val contentJson = JsonStorageCodec.parseToJsonElement(content).toString() ({ - script { unsafe { +"window.renderVocab($contentJson);" } } + span(classes = "vocab") { + attributes["data-vocab"] = contentJson + } }) }), ; diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewTpl.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewTpl.kt index 3b27b41..60da713 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewTpl.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewTpl.kt @@ -81,15 +81,6 @@ fun ApplicationCall.page(pageTitle: String, navBar: List? = null, sideb link(rel = "stylesheet", type = "text/css", href = "/static/style.css") - request.queryParameters["redirect"]?.let { redirect -> - if (redirect == "no") - script { - unsafe { - raw("window.disableFactbookRedirect = true;") - } - } - } - script(src = "/static/init.js") {} } body { @@ -134,36 +125,6 @@ fun ApplicationCall.page(pageTitle: String, navBar: List? = null, sideb } } } - - div { - id = "thumb-view" - div(classes = "bg") - img(alt = "Click to close full size") { - title = "Click to close full size" - } - } - - script { - unsafe { - raw("window.handleFullSizeImages();") - } - } - - this@page.attributes.getOrNull(ErrorMessageAttributeKey)?.let { errorMessage -> - div { - id = "error-popup" - - val paramsWithoutError = parametersOf(request.queryParameters.toMap() - "error") - val newQueryString = paramsWithoutError.toQueryString() - attributes["data-redirect-url"] = "${request.path()}$newQueryString" - - div(classes = "bg") - div(classes = "msg") { - p { +errorMessage } - p { +"Click to close this popup" } - } - } - } } } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsPrefs.kt b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsPrefs.kt index c098c31..f2d6a61 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsPrefs.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/lore/ViewsPrefs.kt @@ -77,7 +77,7 @@ inline fun > FlowOrInteractiveOrPhrasingContent.preference(i entries.joinToHtml(Tag::br) { option -> label { - radioInput(name = inputName) { + radioInput(name = inputName, classes = "pref-$inputName") { value = serializer.getKey(option) ?: "null" required = true checked = current == option diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotApi.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotApi.kt index 4a00559..5ca4e0b 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotApi.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotApi.kt @@ -93,14 +93,14 @@ inline fun HttpRequestBuilder.setJsonBody(body: T) { setBody(body) } -suspend inline fun poll(wait: Long = 1_000L, block: () -> Boolean) { - while (!block()) +suspend inline fun poll(wait: Long = 1_000L, until: () -> Boolean) { + while (!until()) delay(wait) } -suspend inline fun pollValue(wait: Long = 1_000L, block: () -> T?): T { +suspend inline fun pollValue(wait: Long = 1_000L, value: () -> T?): T { while (true) { - block()?.let { return it } + value()?.let { return it } delay(wait) } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotService.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotService.kt index 486c87d..830bf15 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/robot/RobotService.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/robot/RobotService.kt @@ -108,7 +108,11 @@ class RobotService( } private suspend fun deleteThread(threadId: RobotThreadId) { - robotClient.deleteThread(threadId) + try { + robotClient.deleteThread(threadId) + } catch (ex: ClientRequestException) { + logger.warn("Unable to delete thread at ID $threadId", ex) + } (RobotGlobals.get() ?: RobotGlobals()).minusThread(threadId).save() } @@ -241,7 +245,11 @@ class RobotService( if (assistants.isEmpty()) break assistants.map { it.id }.forEach { - robotClient.deleteAssistant(it) + try { + robotClient.deleteAssistant(it) + } catch (ex: ClientRequestException) { + logger.warn("Unable to delete assistant at ID $it", ex) + } } } @@ -250,12 +258,25 @@ class RobotService( if (vectorStores.isEmpty()) break vectorStores.map { it.id }.forEach { - robotClient.deleteVectorStore(it) + try { + robotClient.deleteVectorStore(it) + } catch (ex: ClientRequestException) { + logger.warn("Unable to delete vector-store at ID $it", ex) + } } } - robotClient.listFiles().data.map { it.id }.forEach { - robotClient.deleteFile(it) + while (true) { + val files = robotClient.listFiles().data + if (files.isEmpty()) break + + files.map { it.id }.forEach { + try { + robotClient.deleteFile(it) + } catch (ex: ClientRequestException) { + logger.warn("Unable to delete file at ID $it", ex) + } + } } initialize() diff --git a/src/jvmMain/kotlin/info/mechyrdia/robot/ViewsRobot.kt b/src/jvmMain/kotlin/info/mechyrdia/robot/ViewsRobot.kt index 7e8b83a..fa93817 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/robot/ViewsRobot.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/robot/ViewsRobot.kt @@ -4,7 +4,7 @@ import info.mechyrdia.auth.createCsrfToken import info.mechyrdia.data.currentNation import info.mechyrdia.lore.adminPage import info.mechyrdia.lore.page -import info.mechyrdia.lore.redirectHref +import info.mechyrdia.lore.redirectHrefWithError import info.mechyrdia.lore.standardNavBar import info.mechyrdia.route.Root import info.mechyrdia.route.checkCsrfToken @@ -15,10 +15,9 @@ import io.ktor.server.websocket.* import io.ktor.websocket.* import io.ktor.websocket.CloseReason.* import kotlinx.html.* -import kotlinx.serialization.json.JsonPrimitive suspend fun ApplicationCall.robotPage(): HTML.() -> Unit { - val nation = currentNation()?.id ?: redirectHref(Root.Auth.LoginPage(Root.Auth(Root(error = "You must be logged in to use the NUKE")))) + val nation = currentNation()?.id ?: redirectHrefWithError(Root.Auth.LoginPage(), error = "You must be logged in to use the NUKE") val exhausted = RobotUser.getTokens(nation) >= RobotUser.getMaxTokens(nation) val nukeRoute = href(Root.Nuke.WS()) @@ -34,20 +33,16 @@ suspend fun ApplicationCall.robotPage(): HTML.() -> Unit { b { +"NUKE" } +" (Natural-language Universal Knowledge Engine) is an interactive encyclopedia that answers questions about the galaxy." } - if (exhausted) - p { +"You have exhausted your monthly limit of NUKE usage." } - else - when (robotServiceStatus) { - RobotServiceStatus.NOT_CONFIGURED -> p { +"Unfortunately, the NUKE is not configured on this website." } - RobotServiceStatus.LOADING -> p { +"The NUKE is still in the process of initializing." } - RobotServiceStatus.FAILED -> p { +"Tragically, the NUKE has failed to initialize due to an internal error." } - RobotServiceStatus.READY -> script { - unsafe { - val jsToken = JsonPrimitive(token).toString() - +"window.createNukeBox($jsToken);" - } - } - } + + when (robotServiceStatus) { + RobotServiceStatus.NOT_CONFIGURED -> p { +"Unfortunately, the NUKE is not configured on this website." } + RobotServiceStatus.LOADING -> p { +"The NUKE is still in the process of initializing." } + RobotServiceStatus.FAILED -> p { +"Tragically, the NUKE has failed to initialize due to an internal error." } + RobotServiceStatus.READY -> if (exhausted) + p { +"You have exhausted your monthly limit of NUKE usage." } + else + span(classes = "nuke-box") { attributes["data-ws-csrf-token"] = token } + } } } } diff --git a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt index 32d89d7..e3fb1bc 100644 --- a/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt +++ b/src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt @@ -19,12 +19,14 @@ import io.ktor.util.* import io.ktor.util.pipeline.* import kotlinx.coroutines.delay +const val ErrorMessageCookieName = "ERROR_MSG" + val ErrorMessageAttributeKey = AttributeKey("Mechyrdia.ErrorMessage") @Resource("/") -class Root(val error: String? = null) : ResourceHandler, ResourceFilter { +class Root : ResourceHandler, ResourceFilter { override suspend fun PipelineContext.filterCall() { - error?.let { call.attributes.put(ErrorMessageAttributeKey, it) } + call.request.cookies[ErrorMessageCookieName]?.let { call.attributes.put(ErrorMessageAttributeKey, it) } } override suspend fun PipelineContext.handleCall() { diff --git a/src/jvmMain/resources/static/init.js b/src/jvmMain/resources/static/init.js index 23fd68f..10a826e 100644 --- a/src/jvmMain/resources/static/init.js +++ b/src/jvmMain/resources/static/init.js @@ -11,24 +11,64 @@ }); })(); + /** + * @returns {Object.} + */ + function getCookieMap() { + return document.cookie + .split(";") + .reduce((obj, entry) => { + const trimmed = entry.trim(); + const eqI = trimmed.indexOf('='); + const key = trimmed.substring(0, eqI).trimEnd(); + const value = trimmed.substring(eqI + 1).trimStart(); + return {...obj, [key]: value}; + }, {}); + } + + /** + * @param {ParentNode} element + */ + function clearChildren(element) { + while (element.hasChildNodes()) { + element.firstChild.remove(); + } + } + + /** + * @param {number} amount + * @return {Promise} + */ function delay(amount) { return new Promise(resolve => window.setTimeout(resolve, amount)); } + /** + * @return {Promise} + */ function frame() { return new Promise(resolve => window.requestAnimationFrame(resolve)); } + /** + * @param {string} url + * @return {Promise} + */ function loadScript(url) { return new Promise((resolve, reject) => { const script = document.createElement('script'); - script.onload = () => resolve(); - script.onerror = event => reject(event); + script.addEventListener("load", () => resolve()); + script.addEventListener("error", e => reject(e)); script.src = url; document.head.appendChild(script); }); } + /** + * @param {ParentNode} element + * @param {string} text + * @return {void} + */ function appendWithLineBreaks(element, text) { const lines = text.split("\n"); let isFirst = true; @@ -41,340 +81,44 @@ } } - window.addEventListener("load", function () { - // Mechyrdian font - async function mechyrdianToFont(input, boldOpt, italicOpt, alignOpt, output, delayLength) { - const inText = input.value; - - await delay(delayLength); - if (inText !== input.value) return; - - let outBlob; - if (inText.trim().length === 0) { - outBlob = new Blob([ - "\n", - "\n", - "\n" - ], {type: "image/svg+xml"}); - } else { - const urlParams = new URLSearchParams(); - if (boldOpt.checked) urlParams.set("bold", "true"); - if (italicOpt.checked) urlParams.set("italic", "true"); - urlParams.set("align", alignOpt.value); - - for (const line of inText.split("\n")) - urlParams.append("lines", line.trim()); - - outBlob = await (await fetch('/utils/mechyrdia-sans', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: urlParams, - })).blob(); - - if (inText !== input.value) return; - } - - const prevObjectUrl = output.src; - if (prevObjectUrl != null && prevObjectUrl.length > 0) - URL.revokeObjectURL(prevObjectUrl); - - output.src = URL.createObjectURL(outBlob); - } - - const mechyrdiaSansBoxes = document.getElementsByClassName("mechyrdia-sans-box"); - for (const mechyrdiaSansBox of mechyrdiaSansBoxes) { - const inputBox = mechyrdiaSansBox.getElementsByClassName("input-box")[0]; - const boldOpt = mechyrdiaSansBox.getElementsByClassName("bold-option")[0]; - const italicOpt = mechyrdiaSansBox.getElementsByClassName("ital-option")[0]; - const alignOpt = mechyrdiaSansBox.getElementsByClassName("align-opts")[0]; - const outputBox = mechyrdiaSansBox.getElementsByClassName("output-img")[0]; - - const inputListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 750); - const optChangeListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 250); - inputBox.addEventListener("input", inputListener); - boldOpt.addEventListener("change", optChangeListener); - italicOpt.addEventListener("change", optChangeListener); - alignOpt.addEventListener("change", optChangeListener); - } - }); - - window.addEventListener("load", function () { - // Tylan alphabet - async function tylanToFont(input, output) { - const inText = input.value; - - const urlParams = new URLSearchParams(); - for (const line of inText.split("\n")) - urlParams.append("lines", line.trim()); - - const outText = await (await fetch('/utils/tylan-lang', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: urlParams, - })).text(); - - if (inText === input.value) - output.value = outText; - } - - const tylanAlphabetBoxes = document.getElementsByClassName("tylan-alphabet-box"); - for (const tylanAlphabetBox of tylanAlphabetBoxes) { - const inputBox = tylanAlphabetBox.getElementsByClassName("input-box")[0]; - const outputBox = tylanAlphabetBox.getElementsByClassName("output-box")[0]; - - inputBox.addEventListener("input", () => tylanToFont(inputBox, outputBox)); - } - }); - - window.addEventListener("load", function () { - // Thedish alphabet - const thedishAlphabetBoxes = document.getElementsByClassName("thedish-alphabet-box"); - for (const thedishAlphabetBox of thedishAlphabetBoxes) { - const inputBox = thedishAlphabetBox.getElementsByClassName("input-box")[0]; - const outputBox = thedishAlphabetBox.getElementsByClassName("output-box")[0]; - - inputBox.addEventListener("input", () => { - outputBox.value = inputBox.value; - }); - } - }); - - window.addEventListener("load", function () { - // Kishari alphabet - const kishariAlphabetBoxes = document.getElementsByClassName("kishari-alphabet-box"); - for (const kishariAlphabetBox of kishariAlphabetBoxes) { - const inputBox = kishariAlphabetBox.getElementsByClassName("input-box")[0]; - const outputBox = kishariAlphabetBox.getElementsByClassName("output-box")[0]; - - inputBox.addEventListener("input", () => { - outputBox.value = inputBox.value; - }); - } - }); - - window.addEventListener("load", function () { - // Pokhwalish alphabet - async function pokhwalToFont(input, output) { - const inText = input.value; - - const urlParams = new URLSearchParams(); - for (const line of inText.split("\n")) - urlParams.append("lines", line.trim()); - - const outText = await (await fetch('/utils/pokhwal-lang', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: urlParams, - })).text(); - - if (inText === input.value) - output.value = outText; - } - - const pokhwalAlphabetBoxes = document.getElementsByClassName("pokhwal-alphabet-box"); - for (const pokhwalAlphabetBox of pokhwalAlphabetBoxes) { - const inputBox = pokhwalAlphabetBox.getElementsByClassName("input-box")[0]; - const outputBox = pokhwalAlphabetBox.getElementsByClassName("output-box")[0]; - - inputBox.addEventListener("input", () => pokhwalToFont(inputBox, outputBox)); - } - }); - - window.addEventListener("load", function () { - // Set client preferences when selected - const themeChoices = document.getElementsByName("theme"); - for (const themeChoice of themeChoices) { - themeChoice.addEventListener("click", e => { - const theme = e.currentTarget.value; - if (theme === "null") { - document.documentElement.removeAttribute("data-theme"); - } else { - document.documentElement.setAttribute("data-theme", theme); - } - document.cookie = "FACTBOOK_THEME=" + theme + "; Secure; SameSite=Lax; Max-Age=" + (Math.pow(2, 31) - 1).toString(); - }); - } - - const april1stChoices = document.getElementsByName("april1st"); - for (const april1stChoice of april1stChoices) { - april1stChoice.addEventListener("click", e => { - const mode = e.currentTarget.value; - document.cookie = "APRIL_1ST_MODE=" + mode + "; Secure; SameSite=None; Max-Age=" + (Math.pow(2, 31) - 1).toString(); - }); - } - }); - - window.addEventListener("load", function () { - // Localize dates and times - const moments = document.getElementsByClassName("moment"); - for (const moment of moments) { - let date = new Date(Number(moment.textContent.trim())); - moment.innerHTML = date.toLocaleString(); - moment.style.display = "inline"; - } - }); - - window.addEventListener("load", function () { - // Login button - const viewChecksumButtons = document.getElementsByClassName("view-checksum"); - for (const viewChecksumButton of viewChecksumButtons) { - const token = viewChecksumButton.getAttribute("data-token"); - const url = (token != null && token !== "") ? ("https://www.nationstates.net/page=verify_login?token=" + token) : "https://www.nationstates.net/page=verify_login" - viewChecksumButton.addEventListener("click", e => { - e.preventDefault(); - window.open(url); - }); - } - }); - - window.appendImageThumb = function (src, sizeStyle) { - // Image previewing (1) - const imgElement = document.createElement("img"); - imgElement.src = src; - imgElement.setAttribute("title", "Click to view full size"); - imgElement.setAttribute("style", sizeStyle); - - imgElement.onclick = e => { - e.preventDefault(); - - const thumbView = document.getElementById("thumb-view"); - const thumbViewImg = thumbView.getElementsByTagName("img")[0]; - thumbViewImg.src = e.currentTarget.src; - thumbView.classList.add("visible"); - } - - document.currentScript.after(imgElement); - }; - - window.handleFullSizeImages = function () { - // Image previewing (2) - document.getElementById("thumb-view").addEventListener("click", e => { - e.preventDefault(); - - e.currentTarget.classList.remove("visible"); - e.currentTarget.getElementsByTagName("img")[0].src = ""; - }); - }; - - window.addEventListener("load", function () { - // Mesh viewing - - async function loadThree() { - await loadScript("/static/obj-viewer/three.js"); - await loadScript("/static/obj-viewer/three-examples.js"); - } - + /** + * @returns {Promise>} + */ + async function loadThreeJs() { + await loadScript("/static/obj-viewer/three.js"); + await loadScript("/static/obj-viewer/three-examples.js"); + + /** + * @param {string} modelName + * @returns {Promise} + */ async function loadObj(modelName) { + const THREE = window.THREE; const mtlLib = await (new THREE.MTLLoader()).setPath("/assets/meshes/").setResourcePath("/assets/meshes/").loadAsync(modelName + ".mtl"); mtlLib.preload(); return await (new THREE.OBJLoader()).setPath("/assets/meshes/").setResourcePath("/assets/meshes/").setMaterials(mtlLib).loadAsync(modelName + ".obj"); } - const canvases = document.getElementsByTagName("canvas"); - if (canvases.length > 0) { - (async () => { - await loadThree(); - - const promises = []; - for (const canvas of canvases) { - const modelName = canvas.getAttribute("data-model"); - if (modelName == null || modelName === "") continue; - - promises.push((async () => { - const modelAsync = loadObj(modelName); - - const camera = new THREE.PerspectiveCamera(69, 1, 0.01, 1000.0); - - const scene = new THREE.Scene(); - scene.add(new THREE.AmbientLight("#555555", 1.0)); - - const renderer = new THREE.WebGLRenderer({"canvas": canvas, "antialias": true}); - - const controls = new THREE.OrbitControls(camera, canvas); - - function render() { - controls.update(); - renderer.render(scene, camera); - window.requestAnimationFrame(render); - } - - function onResize() { - const dim = canvas.getBoundingClientRect(); - camera.aspect = dim.width / dim.height; - camera.updateProjectionMatrix(); - renderer.setSize(dim.width, dim.height, false); - } - - window.addEventListener('resize', onResize); - await frame(); - onResize(); - - const model = await modelAsync; - scene.add(model); - - const bbox = new THREE.Box3().setFromObject(scene); - bbox.dimensions = { - x: bbox.max.x - bbox.min.x, - y: bbox.max.y - bbox.min.y, - z: bbox.max.z - bbox.min.z - }; - model.position.sub(new THREE.Vector3(bbox.min.x + bbox.dimensions.x / 2, bbox.min.y + bbox.dimensions.y / 2, bbox.min.z + bbox.dimensions.z / 2)); - - camera.position.set(bbox.dimensions.x / 2, bbox.dimensions.y / 2, Math.max(bbox.dimensions.x, bbox.dimensions.y, bbox.dimensions.z)); - - const light = new THREE.PointLight("#AAAAAA", 1.0); - scene.add(camera); - camera.add(light); - light.position.set(0, 0, 0); - - render(); - })()); - } - - await Promise.all(promises); - })().catch(reason => { - console.error("Error rendering models", reason); - }); - } - }); - - window.addEventListener("load", function () { - // Allow POSTing with s - const anchors = document.getElementsByTagName("a"); - for (const anchor of anchors) { - const method = anchor.getAttribute("data-method"); - if (method == null) continue; - - anchor.onclick = e => { - e.preventDefault(); - - let form = document.createElement("form"); - form.style.display = "none"; - form.action = e.currentTarget.href; - form.method = e.currentTarget.getAttribute("data-method"); - - const csrfToken = e.currentTarget.getAttribute("data-csrf-token"); - if (csrfToken != null) { - let csrfInput = document.createElement("input"); - csrfInput.name = "csrfToken"; - csrfInput.type = "hidden"; - csrfInput.value = csrfToken; - form.append(csrfInput); - } - - document.body.append(form); - form.submit(); - }; - } - }); + return loadObj; + } - window.renderVocab = function (vocab) { + /** + * @typedef {{tag: string, attrs: Object., text: (string|{form: string, regexp: string, replacement: string})}} VocabInflectionTableCell + * @typedef {Array.} VocabInflectionTableRow + * @typedef {Array.} VocabInflectionTable + * @typedef {{type: string, inEnglish: Array., forms: Array., definitions: Array.}} VocabWordEntry + * @typedef {Array.} VocabWord + * @typedef {{langName: string, inflections: Object., words: Object.}} Vocab + * + * @param {Vocab} vocab + * @returns {HTMLDivElement} + */ + function renderVocab(vocab) { + /** + * @param {string} word + * @param {number} index + * @returns {HTMLDivElement} + */ function renderWord(word, index) { const wordRoot = document.createElement("div"); @@ -455,14 +199,12 @@ vocabSearchButton.type = "submit"; vocabSearchButton.value = "Search"; - vocabSearchRoot.onsubmit = function (ev) { - ev.preventDefault(); + vocabSearchRoot.addEventListener("submit", function (e) { + e.preventDefault(); const searchTerm = vocabSearch.value.trim(); - while (vocabSearchResults.hasChildNodes()) { - vocabSearchResults.firstChild.remove(); - } + clearChildren(vocabSearchResults); const searchResults = []; if (vocabEnglishToLang.checked) { @@ -496,31 +238,33 @@ for (const searchResult of searchResults) { vocabSearchResults.append(renderWord(searchResult.word, searchResult.index)); } - }; + }); - document.currentScript.after(vocabRoot); - }; + return vocabRoot; + } - window.renderQuiz = function (quiz) { - const quizFunctions = {}; + /** + * @typedef {{name: string, desc: string, img: string, url: string}} QuizOutcome + * @typedef {{answer: string, result: Object.}} QuizQuestionAnswer + * @typedef {{asks: string, answers: Object.}} QuizQuestion + * @typedef {{title: string, intro: string, image: string, outcomes: Object., questions: Array.}} Quiz + * + * @param {Quiz} quiz + * @returns {HTMLTableElement} + */ + function renderQuiz(quiz) { const quizRoot = document.createElement("table"); const questionAnswers = []; - quizFunctions.clearRoot = function () { - while (quizRoot.hasChildNodes()) { - quizRoot.firstChild.remove(); - } - }; - - quizFunctions.renderIntro = function () { - quizFunctions.clearRoot(); + function renderIntro() { + clearChildren(quizRoot); const firstRow = document.createElement("tr"); const firstCell = document.createElement("td"); firstCell.style.textAlign = "center"; firstCell.style.fontSize = "1.5em"; firstCell.style.fontWeight = "bold"; - firstCell.append(quiz.title.toString()); + firstCell.append(quiz.title); firstRow.appendChild(firstCell); quizRoot.appendChild(firstRow); @@ -528,7 +272,7 @@ const secondCell = document.createElement("td"); secondCell.style.textAlign = "center"; secondCell.appendChild(document.createElement("img")).src = quiz.image; - for (const paragraph of quiz.intro.toString().split('\n')) { + for (const paragraph of quiz.intro.split('\n')) { secondCell.appendChild(document.createElement("p")).append(paragraph); } secondRow.appendChild(secondCell); @@ -540,23 +284,26 @@ const beginLink = thirdCell.appendChild(document.createElement("a")); beginLink.href = "#"; beginLink.append("Begin Quiz (" + quiz.questions.length + " questions)"); - beginLink.onclick = e => { + beginLink.addEventListener("click", e => { e.preventDefault(); - quizFunctions.renderQuestion(0); - }; + renderQuestion(0); + }); thirdRow.appendChild(thirdCell); quizRoot.appendChild(thirdRow); - }; + } - quizFunctions.renderOutro = function (outcome) { - quizFunctions.clearRoot(); + /** + * @param {QuizOutcome} outcome + */ + function renderOutro(outcome) { + clearChildren(quizRoot); const firstRow = document.createElement("tr"); const firstCell = document.createElement("td"); firstCell.style.textAlign = "center"; firstCell.style.fontSize = "1.5em"; firstCell.style.fontWeight = "bold"; - firstCell.append(outcome.name.toString()); + firstCell.append(outcome.name); firstRow.appendChild(firstCell); quizRoot.appendChild(firstRow); @@ -564,7 +311,7 @@ const secondCell = document.createElement("td"); secondCell.style.textAlign = "center"; secondCell.appendChild(document.createElement("img")).src = outcome.img; - for (const paragraph of outcome.desc.toString().split('\n')) { + for (const paragraph of outcome.desc.split('\n')) { secondCell.appendChild(document.createElement("p")).append(paragraph); } secondRow.appendChild(secondCell); @@ -578,9 +325,12 @@ moreInfoLink.append("More Information"); thirdRow.appendChild(thirdCell); quizRoot.appendChild(thirdRow); - }; + } - quizFunctions.calculateResults = function () { + /** + * @returns {QuizOutcome} + */ + function calculateResults() { const total = {}; for (const result of questionAnswers) { for (const resKey of Object.keys(result)) { @@ -602,10 +352,13 @@ } return quiz.outcomes[maxKey]; - }; + } - quizFunctions.renderQuestion = function (index) { - quizFunctions.clearRoot(); + /** + * @param {number} index + */ + function renderQuestion(index) { + clearChildren(quizRoot); const question = quiz.questions[index]; @@ -616,7 +369,7 @@ firstCell.style.fontWeight = "bold"; firstCell.append("Question " + (index + 1) + "/" + quiz.questions.length); firstCell.append(document.createElement("br")); - firstCell.append(question.asks.toString()); + firstCell.append(question.asks); firstRow.appendChild(firstCell); quizRoot.appendChild(firstRow); @@ -626,16 +379,16 @@ secondCell.style.textAlign = "center"; const answerLink = secondCell.appendChild(document.createElement("a")); answerLink.href = "#"; - answerLink.append(answer.answer.toString()); - answerLink.onclick = e => { + answerLink.append(answer.answer); + answerLink.addEventListener("click", e => { e.preventDefault(); questionAnswers[index] = answer.result; if (index === quiz.questions.length - 1) { - quizFunctions.renderOutro(quizFunctions.calculateResults()); + renderOutro(calculateResults()); } else { - quizFunctions.renderQuestion(index + 1); + renderQuestion(index + 1); } - }; + }); secondRow.appendChild(secondCell); quizRoot.appendChild(secondRow); } @@ -646,268 +399,699 @@ const prevLink = thirdCell.appendChild(document.createElement("a")); prevLink.href = "#"; prevLink.append("Previous Question"); - prevLink.onclick = e => { + prevLink.addEventListener("click", e => { e.preventDefault(); if (index === 0) { - quizFunctions.renderIntro(); + renderIntro(); } else { - quizFunctions.renderQuestion(index - 1); + renderQuestion(index - 1); } - }; + }); thirdRow.appendChild(thirdCell); quizRoot.appendChild(thirdRow); - }; + } - document.currentScript.after(quizRoot); + renderIntro(); - quizFunctions.renderIntro(); - }; + return quizRoot; + } - window.addEventListener("load", function () { - // Comment previews - async function commentPreview(input, output) { - const inText = input.value; + /** + * @param {HTMLElement} dom + */ + function onDomLoad(dom) { + (function () { + // Mechyrdian font + + /** + * @param {HTMLInputElement} input + * @param {HTMLInputElement} boldOpt + * @param {HTMLInputElement} italicOpt + * @param {HTMLSelectElement} alignOpt + * @param {HTMLImageElement} output + * @param {number} delayLength + * @returns {Promise} + */ + async function mechyrdianToFont(input, boldOpt, italicOpt, alignOpt, output, delayLength) { + const inText = input.value; + + await delay(delayLength); + if (inText !== input.value) return; - await delay(500); - if (input.value !== inText) - return; + let outBlob; + if (inText.trim().length === 0) { + outBlob = new Blob([ + "\n", + "\n", + "\n" + ], {type: "image/svg+xml"}); + } else { + const urlParams = new URLSearchParams(); + if (boldOpt.checked) urlParams.set("bold", "true"); + if (italicOpt.checked) urlParams.set("italic", "true"); + urlParams.set("align", alignOpt.value); + + for (const line of inText.split("\n")) + urlParams.append("lines", line.trim()); + + outBlob = await (await fetch('/utils/mechyrdia-sans', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: urlParams, + })).blob(); + + if (inText !== input.value) return; + } - if (inText.length === 0) { - output.innerHTML = ""; - return; + const prevObjectUrl = output.src; + if (prevObjectUrl != null && prevObjectUrl.length > 0) + URL.revokeObjectURL(prevObjectUrl); + + output.src = URL.createObjectURL(outBlob); } - const urlParams = new URLSearchParams(); - for (const line of inText.split("\n")) - urlParams.append("lines", line.trim()); + const mechyrdiaSansBoxes = dom.querySelectorAll("div.mechyrdia-sans-box"); + for (const mechyrdiaSansBox of mechyrdiaSansBoxes) { + const inputBox = mechyrdiaSansBox.querySelector("textarea.input-box"); + const boldOpt = mechyrdiaSansBox.querySelector("input.bold-option"); + const italicOpt = mechyrdiaSansBox.querySelector("input.ital-option"); + const alignOpt = mechyrdiaSansBox.querySelector("select.align-opts"); + const outputBox = mechyrdiaSansBox.querySelector("img.output-img"); + + const inputListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 750); + const optChangeListener = () => mechyrdianToFont(inputBox, boldOpt, italicOpt, alignOpt, outputBox, 250); + inputBox.addEventListener("input", inputListener); + boldOpt.addEventListener("change", optChangeListener); + italicOpt.addEventListener("change", optChangeListener); + alignOpt.addEventListener("change", optChangeListener); + } + })(); - const outText = await (await fetch('/utils/preview-comment', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: urlParams, - })).text(); - if (input.value !== inText) - return; + (function () { + // Tylan alphabet - output.innerHTML = "

Preview:

" + outText; - } + /** + * @param {HTMLTextAreaElement} input + * @param {HTMLTextAreaElement} output + * @returns {Promise} + */ + async function tylanToFont(input, output) { + const inText = input.value; - const commentInputBoxes = document.getElementsByClassName("comment-input"); - for (const commentInputBox of commentInputBoxes) { - const inputBox = commentInputBox.getElementsByClassName("comment-markup")[0]; - const outputBox = commentInputBox.getElementsByClassName("comment-preview")[0]; + const urlParams = new URLSearchParams(); + for (const line of inText.split("\n")) + urlParams.append("lines", line.trim()); - inputBox.addEventListener("input", () => commentPreview(inputBox, outputBox)); - } - }); + const outText = await (await fetch('/utils/tylan-lang', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: urlParams, + })).text(); - window.addEventListener("load", function () { - // Comment editing - const commentEditLinks = document.getElementsByClassName("comment-edit-link"); - for (const commentEditLink of commentEditLinks) { - commentEditLink.onclick = e => { - e.preventDefault(); + if (inText === input.value) + output.value = outText; + } - const elementId = e.currentTarget.getAttribute("data-edit-id"); - document.getElementById(elementId).classList.add("visible"); - }; - } + const tylanAlphabetBoxes = dom.querySelectorAll("div.tylan-alphabet-box"); + for (const tylanAlphabetBox of tylanAlphabetBoxes) { + const inputBox = tylanAlphabetBox.querySelector("textarea.input-box"); + const outputBox = tylanAlphabetBox.querySelector("textarea.output-box"); - const commentEditCancelButtons = document.getElementsByClassName("comment-cancel-edit"); - for (const commentEditCancelButton of commentEditCancelButtons) { - commentEditCancelButton.onclick = e => { - e.preventDefault(); + inputBox.addEventListener("input", () => tylanToFont(inputBox, outputBox)); + } + })(); - e.currentTarget.parentElement.classList.remove("visible"); - }; - } - }); + (function () { + // Thedish alphabet - window.addEventListener("load", function () { - // Copying text - const copyTextElements = document.getElementsByClassName("copy-text"); - for (const copyTextElement of copyTextElements) { - copyTextElement.onclick = e => { - e.preventDefault(); + const thedishAlphabetBoxes = dom.querySelectorAll("div.thedish-alphabet-box"); + for (const thedishAlphabetBox of thedishAlphabetBoxes) { + const inputBox = thedishAlphabetBox.querySelector("textarea.input-box"); + const outputBox = thedishAlphabetBox.querySelector("textarea.output-box"); - const thisElement = e.currentTarget; - if (thisElement.hasAttribute("data-copying")) - return; + inputBox.addEventListener("input", () => { + outputBox.value = inputBox.value; + }); + } + })(); + + (function () { + // Kishari alphabet + + const kishariAlphabetBoxes = dom.querySelectorAll("div.kishari-alphabet-box"); + for (const kishariAlphabetBox of kishariAlphabetBoxes) { + const inputBox = kishariAlphabetBox.querySelector("textarea.input-box"); + const outputBox = kishariAlphabetBox.querySelector("textarea.output-box"); + + inputBox.addEventListener("input", () => { + outputBox.value = inputBox.value; + }); + } + })(); + + (function () { + // Pokhwalish alphabet + + /** + * @param {HTMLTextAreaElement} input + * @param {HTMLTextAreaElement} output + * @returns {Promise} + */ + async function pokhwalToFont(input, output) { + const inText = input.value; + + const urlParams = new URLSearchParams(); + for (const line of inText.split("\n")) + urlParams.append("lines", line.trim()); + + const outText = await (await fetch('/utils/pokhwal-lang', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: urlParams, + })).text(); + + if (inText === input.value) + output.value = outText; + } + + const pokhwalAlphabetBoxes = dom.querySelectorAll("div.pokhwal-alphabet-box"); + for (const pokhwalAlphabetBox of pokhwalAlphabetBoxes) { + const inputBox = pokhwalAlphabetBox.querySelector("textarea.input-box"); + const outputBox = pokhwalAlphabetBox.querySelector("textarea.output-box"); + + inputBox.addEventListener("input", () => pokhwalToFont(inputBox, outputBox)); + } + })(); + + (function () { + // Set client preferences when selected + const themeChoices = dom.querySelectorAll("input.pref-theme"); + for (const themeChoice of themeChoices) { + themeChoice.addEventListener("click", e => { + const theme = e.currentTarget.value; + if (theme === "null") { + document.documentElement.removeAttribute("data-theme"); + } else { + document.documentElement.setAttribute("data-theme", theme); + } + document.cookie = "FACTBOOK_THEME=" + theme + "; Secure; SameSite=Lax; Max-Age=" + (Math.pow(2, 31) - 1).toString(); + }); + } + + const april1stChoices = dom.querySelectorAll("input.pref-april1st"); + for (const april1stChoice of april1stChoices) { + april1stChoice.addEventListener("click", e => { + const mode = e.currentTarget.value; + document.cookie = "APRIL_1ST_MODE=" + mode + "; Secure; SameSite=None; Max-Age=" + (Math.pow(2, 31) - 1).toString(); + }); + } + })(); + + (function () { + // Localize dates and times + + const moments = dom.querySelectorAll("span.moment"); + for (const moment of moments) { + let date = new Date(Number(moment.textContent.trim())); + moment.innerHTML = date.toLocaleString(); + moment.style.display = "inline"; + } + })(); + + (function () { + // Login button + + const viewChecksumButtons = dom.querySelectorAll("button.view-checksum"); + for (const viewChecksumButton of viewChecksumButtons) { + const token = viewChecksumButton.getAttribute("data-token"); + const url = (token != null && token !== "") ? ("https://www.nationstates.net/page=verify_login?token=" + token) : "https://www.nationstates.net/page=verify_login" + viewChecksumButton.addEventListener("click", e => { + e.preventDefault(); + window.open(url); + }); + } + })(); + + (function () { + // Image previewing + + const imageThumbs = dom.querySelectorAll("span.image-thumb"); + for (const imageThumb of imageThumbs) { + const imgElement = document.createElement("img"); + imgElement.src = imageThumb.getAttribute("data-src"); + imgElement.style.cssText = imageThumb.getAttribute("data-style"); + imgElement.title = "Click to view full size"; - const elementHtml = thisElement.innerHTML; - - thisElement.setAttribute("data-copying", "copying"); - - const text = thisElement.getAttribute("data-text"); - navigator.clipboard.writeText(text) - .then(() => { - thisElement.innerHTML = "Text copied!"; - window.setTimeout(() => { - thisElement.innerHTML = elementHtml; - thisElement.removeAttribute("data-copying"); - }, 750); - }) - .catch(reason => { - console.error("Error copying text to clipboard", reason); - - thisElement.innerHTML = "Text copy failed"; - window.setTimeout(() => { - thisElement.innerHTML = elementHtml; - thisElement.removeAttribute("data-copying"); - }, 1500); + imgElement.addEventListener("click", e => { + e.preventDefault(); + + const thumbView = document.createElement("div"); + thumbView.id = "thumb-view"; + + const thumbViewBg = document.createElement("div"); + thumbViewBg.classList.add("bg"); + + const thumbViewImg = document.createElement("img"); + thumbViewImg.src = e.currentTarget.src; + thumbViewImg.title = thumbViewImg.alt = "Click to close full size"; + thumbView.classList.add("visible"); + + thumbView.append(thumbViewBg, thumbViewImg); + thumbView.addEventListener("click", e => { + e.preventDefault(); + + e.currentTarget.remove(); }); - thisElement.innerHTML = "Copying text..."; - }; - } - }); + document.body.append(thumbView); + }); - window.addEventListener("load", function () { - // Error popup - const errorPopup = document.getElementById("error-popup"); - if (errorPopup != null) { - errorPopup.addEventListener("click", e => { - e.preventDefault(); + imageThumb.after(imgElement); + imageThumb.remove(); + } + })(); - const thisElement = e.currentTarget; - const newUrl = window.location.origin + thisElement.getAttribute("data-redirect-url") + window.location.hash; - window.history.replaceState({}, '', newUrl); + (function () { + // Mesh viewing - thisElement.remove(); - }); - } - }); + const canvases = dom.querySelectorAll("canvas[data-model]"); + if (canvases.length > 0) { + (async function () { + const loadObj = await loadThreeJs(); + const THREE = window.THREE; - window.factbookRedirect = function (redirectTo) { - if (window.disableFactbookRedirect) { - const redirectTarget = new URL(redirectTo, window.location); + const promises = []; + for (const canvas of canvases) { + promises.push((async () => { + const modelAsync = loadObj(canvas.getAttribute("data-model")); - const redirectTargetUrl = redirectTarget.pathname + redirectTarget.search + redirectTarget.hash; + const camera = new THREE.PerspectiveCamera(69, 1, 0.01, 1000.0); - const pElement = document.createElement("p"); - pElement.style.fontWeight = "800"; - pElement.append(document.createTextNode("Redirect to ")); + const scene = new THREE.Scene(); + scene.add(new THREE.AmbientLight("#555555", 1.0)); - const aElement = document.createElement("a"); - aElement.href = redirectTargetUrl; - aElement.append(document.createTextNode(redirectTarget.pathname)); + const renderer = new THREE.WebGLRenderer({"canvas": canvas, "antialias": true}); - pElement.append(aElement); - document.currentScript.after(pElement); - } else { - window.localStorage.setItem("redirectedFrom", window.location.pathname); - window.location = redirectTo; - } - }; + const controls = new THREE.OrbitControls(camera, canvas); - window.checkRedirectTarget = function (anchorHash) { - const redirectTargetValue = window.location.hash; - if (redirectTargetValue !== anchorHash) return; + function render() { + controls.update(); + renderer.render(scene, camera); + window.requestAnimationFrame(render); + } - const redirectSourceValue = window.localStorage.getItem("redirectedFrom"); - if (redirectSourceValue != null) { - const redirectSource = new URL(redirectSourceValue, window.location.origin); - if (redirectSource.search.length > 0) { - redirectSource.search += "&redirect=no"; - } else { - redirectSource.search = "?redirect=no"; + function onResize() { + const dim = canvas.getBoundingClientRect(); + camera.aspect = dim.width / dim.height; + camera.updateProjectionMatrix(); + renderer.setSize(dim.width, dim.height, false); + } + + window.addEventListener('resize', onResize); + await frame(); + onResize(); + + const model = await modelAsync; + scene.add(model); + + const bbox = new THREE.Box3().setFromObject(scene); + bbox.dimensions = { + x: bbox.max.x - bbox.min.x, + y: bbox.max.y - bbox.min.y, + z: bbox.max.z - bbox.min.z + }; + model.position.sub(new THREE.Vector3(bbox.min.x + bbox.dimensions.x / 2, bbox.min.y + bbox.dimensions.y / 2, bbox.min.z + bbox.dimensions.z / 2)); + + camera.position.set(bbox.dimensions.x / 2, bbox.dimensions.y / 2, Math.max(bbox.dimensions.x, bbox.dimensions.y, bbox.dimensions.z)); + + const light = new THREE.PointLight("#AAAAAA", 1.0); + scene.add(camera); + camera.add(light); + light.position.set(0, 0, 0); + + render(); + })()); + } + + await Promise.all(promises); + })().catch(reason => { + console.error("Error rendering models", reason); + }); } + })(); - const redirectSourceUrl = redirectSource.pathname + redirectSource.search + redirectSource.hash; + (function () { + // Allow POSTing with
s - const pElement = document.createElement("p"); - pElement.style.fontSize = "0.8em"; - pElement.append(document.createTextNode("Redirected from ")); + // TODO implement Fetch+History loading + const anchors = dom.querySelectorAll("a[data-method]"); + for (const anchor of anchors) { + anchor.addEventListener("click", e => { + e.preventDefault(); - const aElement = document.createElement("a"); - aElement.href = redirectSourceUrl; - aElement.append(document.createTextNode(redirectSource.pathname)); + let form = document.createElement("form"); + form.style.display = "none"; + form.action = e.currentTarget.href; + form.method = e.currentTarget.getAttribute("data-method"); + + const csrfToken = e.currentTarget.getAttribute("data-csrf-token"); + if (csrfToken != null) { + let csrfInput = document.createElement("input"); + csrfInput.name = "csrfToken"; + csrfInput.type = "hidden"; + csrfInput.value = csrfToken; + form.append(csrfInput); + } - pElement.append(aElement); - document.currentScript.after(pElement); - } + document.body.appendChild(form).submit(); + }); + } + })(); - window.localStorage.removeItem("redirectedFrom"); - }; - - window.createNukeBox = function (csrfToken) { - const chatHistory = document.createElement("blockquote"); - chatHistory.style.overflowY = "scroll"; - chatHistory.style.height = "40vh"; - - const inputBox = document.createElement("input"); - inputBox.classList.add("inline"); - inputBox.style.flexGrow = "1"; - inputBox.type = "text"; - inputBox.placeholder = "Enter your message"; - - const enterBtn = document.createElement("input"); - enterBtn.classList.add("inline"); - enterBtn.style.flexShrink = "0"; - enterBtn.type = "submit"; - enterBtn.value = "Send"; - enterBtn.disabled = true; - - const inputForm = document.createElement("form"); - inputForm.style.display = "flex"; - inputForm.append(inputBox, enterBtn); - - const container = document.createElement("div"); - container.append(chatHistory, inputForm); - document.currentScript.after(container); - - const targetUrl = "ws" + window.location.href.substring(4) + "/ws?csrfToken=" + csrfToken; - const webSock = new WebSocket(targetUrl); - - inputForm.onsubmit = (ev) => { - ev.preventDefault(); - if (!ev.submitter.disabled) { - webSock.send(inputBox.value); - inputBox.value = ""; - enterBtn.disabled = true; + (function () { + // Render vocab + + const vocabSpans = dom.querySelectorAll("span.vocab"); + for (const vocabSpan of vocabSpans) { + const vocab = JSON.parse(vocabSpan.getAttribute("data-vocab")); + vocabSpan.after(renderVocab(vocab)); + vocabSpan.remove(); } - }; - - webSock.onmessage = (ev) => { - const data = JSON.parse(ev.data); - if (data.type === "ready") { - enterBtn.disabled = false; - } else if (data.type === "user") { - const userP = document.createElement("p"); - userP.style.textAlign = "right"; - userP.style.paddingLeft = "50%"; - appendWithLineBreaks(userP, data.text); - chatHistory.appendChild(userP); - - const robotP = document.createElement("p"); - robotP.style.textAlign = "left"; - robotP.style.paddingRight = "50%"; - chatHistory.appendChild(robotP); - } else if (data.type === "robot") { - const robotP = chatHistory.lastElementChild; - appendWithLineBreaks(robotP, data.text); - } else if (data.type === "cite") { - const robotP = chatHistory.lastElementChild; - const robotCiteList = robotP.appendChild(document.createElement("ol")); - for (const url of data.urls) { - const urlLink = robotCiteList.appendChild(document.createElement("li")).appendChild(document.createElement("a")); - urlLink.href = url; - urlLink.append(url); + })(); + + (function () { + // Render quizzes + + const quizSpans = dom.querySelectorAll("span.quiz"); + for (const quizSpan of quizSpans) { + const quiz = JSON.parse(quizSpan.getAttribute("data-quiz")); + quizSpan.after(renderQuiz(quiz)); + quizSpan.remove(); + } + })(); + + (function () { + // Comment previews + + /** + * @param {HTMLTextAreaElement} input + * @param {HTMLDivElement} output + * @returns {Promise} + */ + async function commentPreview(input, output) { + const inText = input.value; + + await delay(500); + if (input.value !== inText) + return; + + if (inText.length === 0) { + output.innerHTML = ""; + return; } + + const urlParams = new URLSearchParams(); + for (const line of inText.split("\n")) + urlParams.append("lines", line.trim()); + + const outText = await (await fetch('/utils/preview-comment', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: urlParams, + })).text(); + if (input.value !== inText) + return; + + output.innerHTML = "

Preview:

" + outText; } - }; - webSock.onclose = (ev) => { - const statusP = document.createElement("p"); - statusP.style.textAlign = "center"; - statusP.style.paddingLeft = "25%"; - statusP.style.paddingRight = "25%"; - appendWithLineBreaks(statusP, "The connection has been closed\n" + ev.reason); - chatHistory.appendChild(statusP); + const commentInputBoxes = dom.querySelectorAll("form.comment-input"); + for (const commentInputBox of commentInputBoxes) { + const inputBox = commentInputBox.querySelector("textarea.comment-markup"); + const outputBox = commentInputBox.querySelector("div.comment-preview"); + + inputBox.addEventListener("input", () => commentPreview(inputBox, outputBox)); + } + })(); + + (function () { + // Comment editing + + const commentEditLinks = dom.querySelectorAll("a.comment-edit-link"); + for (const commentEditLink of commentEditLinks) { + const targetElement = dom.querySelector("#" + commentEditLink.getAttribute("data-edit-id")); + commentEditLink.addEventListener("click", e => { + e.preventDefault(); + + targetElement.classList.add("visible"); + }); + } + + const commentEditCancelButtons = dom.querySelectorAll("button.comment-cancel-edit"); + for (const commentEditCancelButton of commentEditCancelButtons) { + const targetElement = dom.querySelector("#" + commentEditCancelButton.getAttribute("data-edit-id")); + + commentEditCancelButton.addEventListener("click", e => { + e.preventDefault(); + + targetElement.classList.remove("visible"); + }); + } + })(); + + (function () { + // Copying text + + const copyTextElements = dom.querySelectorAll("a.copy-text"); + for (const copyTextElement of copyTextElements) { + copyTextElement.addEventListener("click", e => { + e.preventDefault(); + + const thisElement = e.currentTarget; + if (thisElement.hasAttribute("data-copying")) + return; + + const elementHtml = thisElement.innerHTML; + + thisElement.setAttribute("data-copying", "copying"); + + const text = thisElement.getAttribute("data-text"); + navigator.clipboard.writeText(text) + .then(() => { + thisElement.innerHTML = "Text copied!"; + window.setTimeout(() => { + thisElement.innerHTML = elementHtml; + thisElement.removeAttribute("data-copying"); + }, 750); + }) + .catch(reason => { + console.error("Error copying text to clipboard", reason); + + thisElement.innerHTML = "Text copy failed"; + window.setTimeout(() => { + thisElement.innerHTML = elementHtml; + thisElement.removeAttribute("data-copying"); + }, 1500); + }); + + thisElement.innerHTML = "Copying text..."; + }); + } + })(); + + (function () { + // Error popup + + const errorMsg = getCookieMap()["ERROR_MSG"]; + if (errorMsg != null) { + document.cookie = "ERROR_MSG=; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; Secure"; + + const errorPopup = document.createElement("div"); + errorPopup.id = "error-popup"; + + const errorPopupBg = document.createElement("div"); + errorPopupBg.classList.add("bg"); + + const errorPopupMsg = document.createElement("div"); + errorPopupMsg.classList.add("msg"); + + const msgP = document.createElement("p"); + msgP.append(errorMsg); + const c2cP = document.createElement("p"); + c2cP.append("Click to close this popup"); + + errorPopupMsg.append(msgP, c2cP); + errorPopup.append(errorPopupBg, errorPopupMsg); - enterBtn.disabled = true; - }; - }; + errorPopup.addEventListener("click", e => { + e.preventDefault(); + + e.currentTarget.remove(); + }); + + document.body.append(errorPopup); + } + })(); + + (function () { + // Factbook redirecting (1) + + const redirectLink = dom.querySelector("a.redirect-link"); + if (redirectLink != null) { + const redirectTarget = new URL(redirectLink.href, window.location); + const redirectTargetUrl = redirectTarget.pathname + redirectTarget.search + redirectTarget.hash; + + if (window.localStorage.getItem("disableRedirect") === "true") { + clearChildren(redirectLink); + redirectLink.append(redirectTarget.pathname); + } else { + // The scope-block immediately below - labeled "Factbook redirecting (2)" + // checks if the key "redirectedFrom" is present in localStorage and, if + // so, removes it after some other processing. We don't want that to happen + // if we're setting it and then redirecting, so we have to put this code + // into a microtask so it waits until after the rest of this function executes. + window.queueMicrotask(() => { + window.localStorage.setItem("redirectedFrom", window.location.pathname); + window.location = redirectTargetUrl; + }); + } + } + + window.localStorage.removeItem("disableRedirect"); + })(); + + (function () { + // Factbook redirecting (2) + + const redirectSourceValue = window.localStorage.getItem("redirectedFrom"); + if (redirectSourceValue != null) { + const redirectSource = new URL(redirectSourceValue, window.location.origin); + const redirectSourceUrl = redirectSource.pathname + redirectSource.search + redirectSource.hash; + + const redirectIdValue = window.location.hash; + const redirectIds = dom.querySelectorAll("h1[data-redirect-id], h2[data-redirect-id], h3[data-redirect-id], h4[data-redirect-id], h5[data-redirect-id], h6[data-redirect-id]"); + for (const redirectId of redirectIds) { + if (redirectId.getAttribute("data-redirect-id") !== redirectIdValue) + continue; + + const pElement = document.createElement("p"); + pElement.style.fontSize = "0.8em"; + pElement.append("Redirected from "); + + const aElement = document.createElement("a"); + aElement.href = redirectSourceUrl; + aElement.append(redirectSource.pathname); + aElement.addEventListener("click", e => { + e.preventDefault(); + window.localStorage.setItem("disableRedirect", "true"); + + // TODO implement Fetch+History loading + window.location = e.currentTarget.href; + }); + + pElement.append(aElement); + redirectId.after(pElement); + } + + window.localStorage.removeItem("redirectedFrom"); + } + })(); + + (function () { + // NUKE + + const nukeBoxes = dom.querySelectorAll("span.nuke-box"); + for (const nukeBox of nukeBoxes) { + const chatHistory = document.createElement("blockquote"); + chatHistory.style.overflowY = "scroll"; + chatHistory.style.height = "40vh"; + + const inputBox = document.createElement("input"); + inputBox.classList.add("inline"); + inputBox.style.flexGrow = "1"; + inputBox.type = "text"; + inputBox.placeholder = "Enter your message"; + + const enterBtn = document.createElement("input"); + enterBtn.classList.add("inline"); + enterBtn.style.flexShrink = "0"; + enterBtn.type = "submit"; + enterBtn.value = "Send"; + enterBtn.disabled = true; + + const inputForm = document.createElement("form"); + inputForm.style.display = "flex"; + inputForm.append(inputBox, enterBtn); + + const container = document.createElement("div"); + container.append(chatHistory, inputForm); + nukeBox.after(container); + nukeBox.remove(); + + const targetUrl = "ws" + window.location.href.substring(4) + "/ws?csrfToken=" + nukeBox.getAttribute("data-ws-csrf-token"); + const webSock = new WebSocket(targetUrl); + + inputForm.addEventListener("submit", e => { + e.preventDefault(); + if (!e.submitter.disabled) { + webSock.send(inputBox.value); + inputBox.value = ""; + enterBtn.disabled = true; + } + }); + + webSock.addEventListener("message", e => { + const data = JSON.parse(e.data); + if (data.type === "ready") { + enterBtn.disabled = false; + } else if (data.type === "user") { + const userP = document.createElement("p"); + userP.style.textAlign = "right"; + userP.style.paddingLeft = "50%"; + appendWithLineBreaks(userP, data.text); + chatHistory.appendChild(userP); + + const robotP = document.createElement("p"); + robotP.style.textAlign = "left"; + robotP.style.paddingRight = "50%"; + chatHistory.appendChild(robotP); + } else if (data.type === "robot") { + const robotP = chatHistory.lastElementChild; + appendWithLineBreaks(robotP, data.text); + } else if (data.type === "cite") { + const robotP = chatHistory.lastElementChild; + const robotCiteList = robotP.appendChild(document.createElement("ol")); + for (const url of data.urls) { + const urlLink = robotCiteList.appendChild(document.createElement("li")).appendChild(document.createElement("a")); + urlLink.href = url; + urlLink.append(url); + } + } + }); + + webSock.addEventListener("close", e => { + const statusP = document.createElement("p"); + statusP.style.textAlign = "center"; + statusP.style.paddingLeft = "25%"; + statusP.style.paddingRight = "25%"; + appendWithLineBreaks(statusP, "The connection has been closed\n" + e.reason); + chatHistory.appendChild(statusP); + + enterBtn.disabled = true; + }); + } + })(); + } + + window.addEventListener("load", () => { + onDomLoad(document.documentElement); + }); })(); diff --git a/src/jvmMain/resources/static/style.css b/src/jvmMain/resources/static/style.css index 10458f9..f1796c6 100644 --- a/src/jvmMain/resources/static/style.css +++ b/src/jvmMain/resources/static/style.css @@ -353,11 +353,11 @@ aside.mobile img { div#bg { display: unset; - width: 100vw; - height: 100vh; position: fixed; top: 0; left: 0; + right: 0; + bottom: 0; background-image: var(--bgimg); background-attachment: fixed; @@ -671,18 +671,18 @@ iframe { z-index: 998; position: fixed; - width: 100vw; - height: 100vh; - left: 0; top: 0; + left: 0; + right: 0; + bottom: 0; } #error-popup > .bg { position: fixed; - width: 100vw; - height: 100vh; - left: 0; top: 0; + left: 0; + right: 0; + bottom: 0; background-color: rgba(0, 0, 0, 40%); } @@ -776,27 +776,21 @@ textarea.lang-pokhwal { } #thumb-view { - display: none; -} - -#thumb-view.visible { z-index: 998; - display: unset; - position: fixed; - width: 100vw; - height: 100vh; - left: 0; top: 0; + left: 0; + right: 0; + bottom: 0; } #thumb-view > .bg { position: fixed; - width: 100vw; - height: 100vh; - left: 0; top: 0; + left: 0; + right: 0; + bottom: 0; background-color: rgba(0, 0, 0, 40%); } -- 2.25.1