Allow comments on directory pages as well
authorLanius Trolling <lanius@laniustrolling.dev>
Sun, 19 Feb 2023 17:46:04 +0000 (12:46 -0500)
committerLanius Trolling <lanius@laniustrolling.dev>
Sun, 19 Feb 2023 17:46:04 +0000 (12:46 -0500)
18 files changed:
src/main/kotlin/info/mechyrdia/Factbooks.kt
src/main/kotlin/info/mechyrdia/auth/csrf.kt
src/main/kotlin/info/mechyrdia/auth/views_login.kt
src/main/kotlin/info/mechyrdia/data/comments.kt
src/main/kotlin/info/mechyrdia/data/data.kt
src/main/kotlin/info/mechyrdia/data/nations.kt
src/main/kotlin/info/mechyrdia/data/view_comments.kt
src/main/kotlin/info/mechyrdia/data/views_comment.kt
src/main/kotlin/info/mechyrdia/data/views_user.kt
src/main/kotlin/info/mechyrdia/lore/article_listing.kt
src/main/kotlin/info/mechyrdia/lore/parser_reply.kt
src/main/kotlin/info/mechyrdia/lore/parser_tags.kt
src/main/kotlin/info/mechyrdia/lore/preparser.kt
src/main/kotlin/info/mechyrdia/lore/view_bar.kt
src/main/kotlin/info/mechyrdia/lore/views_error.kt
src/main/kotlin/info/mechyrdia/lore/views_lore.kt
src/main/kotlin/info/mechyrdia/lore/views_prefs.kt
src/main/resources/static/style.css

index 9bcdde7f23f2fa1879ab245c48027f30526f7511..82ad2fd9c84c50bf603636946410b83c5d64c5db 100644 (file)
@@ -89,8 +89,8 @@ fun Application.factbooks() {
                exception<ForbiddenException> { call, _ ->
                        call.respondHtml(HttpStatusCode.Forbidden, call.error403())
                }
-               exception<CsrfFailedException> { call, _ ->
-                       call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired())
+               exception<CsrfFailedException> { call, (_, params) ->
+                       call.respondHtml(HttpStatusCode.Forbidden, call.error403PageExpired(params))
                }
                exception<NullPointerException> { call, _ ->
                        call.respondHtml(HttpStatusCode.NotFound, call.error404())
@@ -99,7 +99,7 @@ fun Application.factbooks() {
                        call.respondHtml(HttpStatusCode.NotFound, call.error404())
                }
                
-               exception<Throwable> { call, ex ->
+               exception<Exception> { call, ex ->
                        call.application.log.error("Got uncaught exception from serving call ${call.callId}", ex)
                        
                        call.respondHtml(HttpStatusCode.InternalServerError, call.error500())
index f8b130b122f356788a9fccad7356add79267399e..b5cfaec0264e7fbb810f06ffd7b18cfeb6d0cbf0 100644 (file)
@@ -1,9 +1,12 @@
 package info.mechyrdia.auth
 
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.NationData
 import io.ktor.http.*
 import io.ktor.server.application.*
 import io.ktor.server.plugins.*
 import io.ktor.server.request.*
+import io.ktor.server.sessions.*
 import io.ktor.server.util.*
 import kotlinx.html.FORM
 import kotlinx.html.hiddenInput
@@ -14,14 +17,16 @@ data class CsrfPayload(
        val route: String,
        val remoteAddress: String,
        val userAgent: String?,
-       val expires: Instant = Instant.now().plusSeconds(3600)
+       val userAccount: Id<NationData>?,
+       val expires: Instant
 )
 
-fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant.now().plusSeconds(3600)) =
+fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant.now().plusSeconds(7200)) =
        CsrfPayload(
                route = route,
                remoteAddress = request.origin.remoteAddress,
                userAgent = request.userAgent(),
+               userAccount = sessions.get<UserSession>()?.nationId,
                expires = withExpiration
        )
 
@@ -42,14 +47,14 @@ suspend fun ApplicationCall.verifyCsrfToken(route: String = request.origin.uri):
        val params = receive<Parameters>()
        val token = params.getOrFail("csrf-token")
        
-       val check = csrfMap.remove(token) ?: throw CsrfFailedException("CSRF token does not exist")
+       val check = csrfMap.remove(token) ?: throw CsrfFailedException("The submitted CSRF token does not exist", params)
        val payload = csrfPayload(route, check.expires)
        if (check != payload)
-               throw CsrfFailedException("CSRF token does not match")
+               throw CsrfFailedException("The submitted CSRF token does not match", params)
        if (payload.expires < Instant.now())
-               throw CsrfFailedException("CSRF token has expired")
+               throw CsrfFailedException("The submitted CSRF token has expired", params)
        
        return params
 }
 
-class CsrfFailedException(override val message: String) : RuntimeException(message)
+data class CsrfFailedException(override val message: String, val formData: Parameters) : RuntimeException(message)
index a8579444e6d3ea567d971f73a6e5eec2b67539ca..c3cf8bfc4830a7bfee08d8bd3133fe2d56c77a5a 100644 (file)
@@ -65,7 +65,7 @@ suspend fun ApplicationCall.loginRoute(): Nothing {
        
        val nation = postParams.getOrFail("nation").toNationId()
        val checksum = postParams.getOrFail("checksum")
-       val token = nsTokenMap[postParams.getOrFail("token")]
+       val token = nsTokenMap.remove(postParams.getOrFail("token"))
                ?: throw MissingRequestParameterException("token")
        
        val result = NSAPI
index 2fa4feba7e36dd9658acc77fcdc78cb91c7573f7..42b9e72bcff90d06c01861942b45c02a819872c5 100644 (file)
@@ -26,7 +26,7 @@ data class Comment(
        val contents: String
 ) : DataDocument<Comment> {
        companion object : TableHolder<Comment> {
-               override val Table: DocumentTable<Comment> = DocumentTable()
+               override val Table = DocumentTable<Comment>()
                
                override suspend fun initialize() {
                        Table.index(Comment::submittedBy, Comment::submittedAt)
@@ -54,7 +54,7 @@ data class CommentReplyLink(
        val repliedAt: @Contextual Instant = Instant.now(),
 ) : DataDocument<CommentReplyLink> {
        companion object : TableHolder<CommentReplyLink> {
-               override val Table: DocumentTable<CommentReplyLink> = DocumentTable()
+               override val Table = DocumentTable<CommentReplyLink>()
                
                override suspend fun initialize() {
                        Table.index(CommentReplyLink::originalPost)
@@ -79,7 +79,9 @@ data class CommentReplyLink(
                        )
                }
                
-               suspend fun deleteComment(deletedReply: Id<Comment>) = updateComment(deletedReply, emptySet())
+               suspend fun deleteComment(deletedReply: Id<Comment>) {
+                       Table.remove(CommentReplyLink::replyingPost eq deletedReply)
+               }
                
                suspend fun getReplies(original: Id<Comment>): List<Id<Comment>> {
                        return Table.filter(CommentReplyLink::originalPost eq original)
index ffc0d4df809ee8f322bf1731313c898b2ab423ef..d2bb3cf982baef9c0a29d7666339e3fe4e696738 100644 (file)
@@ -11,7 +11,8 @@ import kotlinx.coroutines.runBlocking
 import kotlinx.serialization.KSerializer
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
-import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
 import kotlinx.serialization.descriptors.SerialDescriptor
 import kotlinx.serialization.encoding.Decoder
 import kotlinx.serialization.encoding.Encoder
@@ -38,24 +39,19 @@ value class Id<T>(val id: String) {
        }
 }
 
-fun <T, U> Id<T>.reinterpret() = Id<U>(id)
-
 private val secureRandom = SecureRandom.getInstanceStrong()
 private val alphabet = "ABCDEFGHILMNOPQRSTVXYZ0123456789".toCharArray()
 fun <T> Id() = Id<T>(NanoIdUtils.randomNanoId(secureRandom, alphabet, 24))
 
 object IdSerializer : KSerializer<Id<*>> {
-       private val inner = String.serializer()
-       
-       override val descriptor: SerialDescriptor
-               get() = inner.descriptor
+       override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Id", PrimitiveKind.STRING)
        
        override fun serialize(encoder: Encoder, value: Id<*>) {
-               inner.serialize(encoder, value.id)
+               encoder.encodeString(value.id)
        }
        
        override fun deserialize(decoder: Decoder): Id<*> {
-               return Id<Any>(inner.deserialize(decoder))
+               return Id<Any>(decoder.decodeString())
        }
 }
 
@@ -103,8 +99,8 @@ interface DataDocument<T : DataDocument<T>> {
        val id: Id<T>
 }
 
-class DocumentTable<T : DataDocument<T>>(val kclass: KClass<T>) {
-       private suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kclass.simpleName!!, kclass.java).coroutine
+class DocumentTable<T : DataDocument<T>>(val kClass: KClass<T>) {
+       private suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kClass.simpleName!!, kClass.java).coroutine
        
        suspend fun index(vararg properties: KProperty1<T, *>) {
                collection().ensureIndex(*properties)
index c90e7362c047170767ef542eb72209452093e939..c808d9e5afbe2a00204a97ddea37e51e1a5e14d2 100644 (file)
@@ -44,6 +44,14 @@ data class NationData(
        }
 }
 
+val CallNationCacheAttribute = AttributeKey<MutableMap<Id<NationData>, NationData>>("NationCache")
+
+val ApplicationCall.nationCache: MutableMap<Id<NationData>, NationData>
+       get() = attributes.getOrNull(CallNationCacheAttribute)
+               ?: mutableMapOf<Id<NationData>, NationData>().also {
+                       attributes.put(CallNationCacheAttribute, it)
+               }
+
 suspend fun MutableMap<Id<NationData>, NationData>.getNation(id: Id<NationData>): NationData {
        return getOrPut(id) {
                NationData.get(id)
@@ -56,6 +64,6 @@ suspend fun ApplicationCall.currentNation(): NationData? {
        attributes.getOrNull(CallCurrentNationAttribute)?.let { return it }
        
        return sessions.get<UserSession>()?.nationId?.let { id ->
-               NationData.get(id)
+               nationCache.getNation(id)
        }?.also { attributes.put(CallCurrentNationAttribute, it) }
 }
index 427835fba5ec242ee7185ace19248caf4b7212fd..390967eb5c3b4ec6a16eddfd35b8a0c6a896b82f 100644 (file)
@@ -8,6 +8,7 @@ import info.mechyrdia.lore.TextParserState
 import info.mechyrdia.lore.dateTime
 import io.ktor.server.application.*
 import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
 import kotlinx.coroutines.coroutineScope
 import kotlinx.html.*
 import java.time.Instant
@@ -47,7 +48,7 @@ data class CommentRenderData(
                                                        replyLinks = CommentReplyLink.getReplies(comment.id),
                                                )
                                        }
-                               }.map { it.await() }
+                               }.awaitAll()
                        }
                }
        }
index 28c590d5ac2364ab5d9134279917ff66f0c842e1..be3a69f4f29e3220fa35dd942efe95fce38b44ce 100644 (file)
@@ -30,7 +30,13 @@ suspend fun ApplicationCall.recentCommentsPage(): HTML.() -> Unit {
                        ).formUrlEncode()
                )
        
-       val comments = CommentRenderData(Comment.Table.sorted(descending(Comment::submittedAt)).take(limit).toList())
+       val comments = CommentRenderData(
+               Comment.Table
+                       .sorted(descending(Comment::submittedAt))
+                       .take(limit)
+                       .toList(),
+               nationCache
+       )
        
        return page("Recent Comments", standardNavBar()) {
                section {
@@ -89,6 +95,12 @@ suspend fun ApplicationCall.viewCommentRoute(): Nothing {
        
        val comment = Comment.Table.get(commentId)!!
        
+       val currentNation = currentNation()
+       val submitter = nationCache.getNation(comment.submittedBy)
+       
+       if (submitter.isBanned && currentNation?.id != comment.submittedBy && currentNation?.id != OwnerNationId)
+               redirectWithError("/lore/${comment.submittedIn}", "The user who posted that comment is banned from commenting")
+       
        redirect("/lore/${comment.submittedIn}#comment-$commentId")
 }
 
@@ -130,7 +142,7 @@ suspend fun ApplicationCall.deleteCommentPage(): HTML.() -> Unit {
        if (currNation.id != comment.submittedBy && currNation.id != OwnerNationId)
                throw ForbiddenException("Illegal attempt by ${currNation.id} to delete comment by ${comment.submittedBy}")
        
-       val commentDisplay = CommentRenderData(listOf(comment), mutableMapOf(currNation.id to currNation)).single()
+       val commentDisplay = CommentRenderData(listOf(comment), nationCache).single()
        
        return page("Confirm Deletion of Commment", standardNavBar()) {
                section {
index d5838b1618faa1280d49e024012baff5ea87eb13..beaf1f90dbf276b2873d6269802200c274c35fda 100644 (file)
@@ -3,6 +3,7 @@ package info.mechyrdia.data
 import info.mechyrdia.OwnerNationId
 import info.mechyrdia.auth.createCsrfToken
 import info.mechyrdia.auth.verifyCsrfToken
+import info.mechyrdia.lore.NationProfileSidebar
 import info.mechyrdia.lore.page
 import info.mechyrdia.lore.redirect
 import info.mechyrdia.lore.standardNavBar
@@ -13,14 +14,14 @@ import org.litote.kmongo.setValue
 
 suspend fun ApplicationCall.userPage(): HTML.() -> Unit {
        val currNation = currentNation()
-       val viewingNation = NationData.get(Id(parameters["id"]!!))
+       val viewingNation = nationCache.getNation(Id(parameters["id"]!!))
        
        val comments = CommentRenderData(
                Comment.getCommentsBy(viewingNation.id).toList(),
-               mutableMapOf(viewingNation.id to viewingNation)
+               nationCache
        )
        
-       return page(viewingNation.name, standardNavBar()) {
+       return page(viewingNation.name, standardNavBar(), NationProfileSidebar(viewingNation)) {
                section {
                        a { id = "page-top" }
                        h1 { +viewingNation.name }
@@ -55,9 +56,10 @@ suspend fun ApplicationCall.adminBanUserRoute(): Nothing {
        
        verifyCsrfToken()
        
-       val bannedNation = NationData.get(Id(parameters["id"]!!))
+       val bannedNation = nationCache.getNation(Id(parameters["id"]!!))
        
-       NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, true))
+       if (!bannedNation.isBanned)
+               NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, true))
        
        redirect("/user/${bannedNation.id}")
 }
@@ -69,9 +71,10 @@ suspend fun ApplicationCall.adminUnbanUserRoute(): Nothing {
        
        verifyCsrfToken()
        
-       val bannedNation = NationData.get(Id(parameters["id"]!!))
+       val bannedNation = nationCache.getNation(Id(parameters["id"]!!))
        
-       NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, false))
+       if (bannedNation.isBanned)
+               NationData.Table.set(bannedNation.id, setValue(NationData::isBanned, false))
        
        redirect("/user/${bannedNation.id}")
 }
index 025183ccf2b84f33782dbf0f17dec20dfea24e01..a67db387050abf5d2f05b0bd886e6cbb9960d1ff 100644 (file)
@@ -28,3 +28,13 @@ fun List<ArticleNode>.renderInto(list: UL, base: String? = null) {
                }
        }
 }
+
+fun String.toFriendlyIndexTitle() = split('/')
+       .joinToString(separator = " - ") { part ->
+               part.toFriendlyPageTitle()
+       }
+
+fun String.toFriendlyPageTitle() = split('-')
+       .joinToString(separator = " ") { word ->
+               word.lowercase().replaceFirstChar { it.titlecase() }
+       }
index 16f26205c0a59eccbc6ee97c503fd4ec9f209de7..8dee964029ede83112b88e48cdc4fe8e4668fcce 100644 (file)
@@ -16,7 +16,9 @@ class CommentRepliesBuilder {
 enum class TextParserReplyCounterTag(val type: TextParserTagType<CommentRepliesBuilder>) {
        REPLY(
                TextParserTagType.Indirect { _, content, builder ->
-                       builder.addReplyTag(Id(sanitizeLink(content)))
+                       sanitizeId(content)?.let { id ->
+                               builder.addReplyTag(Id(id))
+                       }
                        "[reply]$content[/reply]"
                }
        );
index 3a1cbc18f674cc115b4a3509951f9fa15ebed248..cdd6f154ca2af298fff4ebe3f631b2b6f6f8a28f 100644 (file)
@@ -318,9 +318,9 @@ enum class TextParserCommentTags(val type: TextParserTagType<Unit>) {
        
        REPLY(
                TextParserTagType.Indirect { _, content, _ ->
-                       val id = sanitizeLink(content)
-                       
-                       "<a href=\"/comment/view/$id\">&gt;&gt;$id</a>"
+                       sanitizeId(content)?.let { id ->
+                               "<a href=\"/comment/view/$id\">&gt;&gt;$id</a>"
+                       } ?: "[reply]$content[/reply]"
                }
        ),
        
@@ -343,6 +343,9 @@ val NON_LINK_CHAR = Regex("[^#a-zA-Z\\d\\-._]")
 val DOT_CHARS = Regex("\\.+")
 fun sanitizeLink(html: String) = html.replace(NON_LINK_CHAR, "").replace(DOT_CHARS, ".")
 
+val ID_REGEX = Regex("[A-IL-TVX-Z0-9]{24}")
+fun sanitizeId(html: String) = ID_REGEX.matchEntire(html)?.value
+
 fun getSizeParam(tagParam: String?): Pair<Int?, Int?> = tagParam?.let { resolution ->
        val parts = resolution.split('x')
        parts.getOrNull(0)?.toIntOrNull() to parts.getOrNull(1)?.toIntOrNull()
index 8cfe07015205379bbe52019ed6abaec95bbb4d9d..32b28ecf76e78bab90fa3709f0954728684aee19 100644 (file)
@@ -2,13 +2,11 @@ package info.mechyrdia.lore
 
 import com.samskivert.mustache.Escapers
 import com.samskivert.mustache.Mustache
-import com.samskivert.mustache.Template
 import info.mechyrdia.Configuration
 import info.mechyrdia.JsonFileCodec
 import io.ktor.util.*
 import kotlinx.serialization.json.*
 import java.io.File
-import java.security.MessageDigest
 
 @JvmInline
 value class JsonPath private constructor(private val pathElements: List<String>) {
@@ -76,8 +74,6 @@ object PreParser {
                .defaultValue("{{ MISSING VALUE \"{{name}}\" }}")
                .withLoader { File(Configuration.CurrentConfiguration.templateDir, "$it.tpl").bufferedReader() }
        
-       private val cache = mutableMapOf<String, Template>()
-       
        private fun convertJson(json: JsonElement, currentFile: File): Any? = when (json) {
                JsonNull -> null
                is JsonPrimitive -> if (json.isString) {
@@ -98,13 +94,9 @@ object PreParser {
                                convertJson(JsonFileCodec.parseToJsonElement(file.readText()), file)
                        }
        
-       private val msgDigest = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
-       
        fun preparse(name: String, content: String): String {
                return try {
-                       val contentHash = hex(msgDigest.get().digest(content.toByteArray()))
-                       val template = cache[contentHash] ?: compiler.compile(content)
-                       
+                       val template = compiler.compile(content)
                        val context = loadJsonContext(name)
                        template.execute(context)
                } catch (ex: RuntimeException) {
index aba1c2e9685eacb3f635fbf47df02d8fbfb1928b..4859c259dc2735f0e5ef34218dd96e8a5ecb7002 100644 (file)
@@ -1,8 +1,7 @@
 package info.mechyrdia.lore
 
-import kotlinx.html.ASIDE
-import kotlinx.html.TagConsumer
-import kotlinx.html.div
+import info.mechyrdia.data.NationData
+import kotlinx.html.*
 
 abstract class Sidebar {
        protected abstract fun TagConsumer<*>.display()
@@ -20,3 +19,13 @@ data class PageNavSidebar(val contents: List<NavItem>) : Sidebar() {
                }
        }
 }
+
+data class NationProfileSidebar(val nationData: NationData) : Sidebar() {
+       override fun TagConsumer<*>.display() {
+               img(src = nationData.flag, alt = "Flag of ${nationData.name}", classes = "flag-icon")
+               p {
+                       style = "text-align:center"
+                       +nationData.name
+               }
+       }
+}
index 6c6771d67eab2528f801fb84d4359a292552fa9b..2acb9545d92c08dfe4f59f78f28816e754dc4518 100644 (file)
@@ -3,10 +3,7 @@ package info.mechyrdia.lore
 import io.ktor.http.*
 import io.ktor.server.application.*
 import io.ktor.server.request.*
-import kotlinx.html.HTML
-import kotlinx.html.a
-import kotlinx.html.h1
-import kotlinx.html.p
+import kotlinx.html.*
 
 suspend fun ApplicationCall.error400(): HTML.() -> Unit = page("400 Bad Request", standardNavBar()) {
        section {
@@ -22,9 +19,16 @@ suspend fun ApplicationCall.error403(): HTML.() -> Unit = page("403 Forbidden",
        }
 }
 
-suspend fun ApplicationCall.error403PageExpired(): HTML.() -> Unit = page("Page Expired", standardNavBar()) {
+suspend fun ApplicationCall.error403PageExpired(formData: Parameters): HTML.() -> Unit = page("Page Expired", standardNavBar()) {
        section {
                h1 { +"Page Expired" }
+               formData["comment"]?.let { commentData ->
+                       p { +"The comment you tried to submit had been preserved here:" }
+                       textArea {
+                               readonly = true
+                               +commentData
+                       }
+               }
                p {
                        +"The page you were on has expired."
                        request.header(HttpHeaders.Referrer)?.let { referrer ->
@@ -39,7 +43,11 @@ suspend fun ApplicationCall.error403PageExpired(): HTML.() -> Unit = page("Page
 suspend fun ApplicationCall.error404(): HTML.() -> Unit = page("404 Not Found", standardNavBar()) {
        section {
                h1 { +"404 Not Found" }
-               p { +"Unfortunately, we could not find what you were looking for." }
+               p {
+                       +"Unfortunately, we could not find what you were looking for. Would you like to "
+                       a(href = "/") { +"return to the index page" }
+                       +"?"
+               }
        }
 }
 
index 9b633cfec00ff7ff3bb5b6b70d0f21827e4cae15..6ca8c9a62c0b720dbada9484f250ef9787ca13bd 100644 (file)
@@ -16,20 +16,46 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
        val pageFile = File(Configuration.CurrentConfiguration.articleDir).combineSafe(pagePath)
        val pageNode = pageFile.toArticleNode()
        
+       val (canCommentAs, comments) = coroutineScope {
+               val canCommentAs = async { currentNation() }
+               val comments = async {
+                       CommentRenderData(Comment.getCommentsIn(pagePath).toList(), nationCache)
+               }
+               
+               canCommentAs.await() to comments.await()
+       }
+       
        if (pageFile.isDirectory) {
                val navbar = standardNavBar(pagePathParts.takeIf { it.isNotEmpty() })
                
-               val title = pagePath.takeIf { it.isNotEmpty() } ?: "Mechyrdia Infobase"
+               val title = pagePath.takeIf { it.isNotEmpty() }?.toFriendlyIndexTitle() ?: "Mechyrdia Infobase"
+               
+               val sidebar = PageNavSidebar(
+                       listOf(
+                               NavLink("#page-top", title, aClasses = "left"),
+                               NavLink("#comments", "Comments", aClasses = "left")
+                       )
+               )
                
-               return page(title, navbar, null) {
+               return page(title, navbar, sidebar) {
                        section {
+                               a { id = "page-top" }
                                h1 { +title }
                                ul {
                                        pageNode.subNodes.renderInto(this, pagePath.takeIf { it.isNotEmpty() })
                                }
                        }
+                       section {
+                               h2 {
+                                       a { id = "comments" }
+                                       +"Comments"
+                               }
+                               commentInput(pagePath, canCommentAs)
+                               for (comment in comments)
+                                       commentBox(comment, canCommentAs?.id)
+                       }
                }
-       } else {
+       } else if (pageFile.isFile) {
                val pageTemplate = pageFile.readText()
                val pageMarkup = PreParser.preparse(pagePath, pageTemplate)
                val pageHtml = TextParserState.parseText(pageMarkup, TextParserFormattingTag.asTags, Unit)
@@ -39,15 +65,6 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
                
                val pageNav = pageToC.toNavBar() + NavLink("#comments", "Comments", aClasses = "left")
                
-               val (canCommentAs, comments) = coroutineScope {
-                       val canCommentAs = async { currentNation() }
-                       val comments = async {
-                               CommentRenderData(Comment.getCommentsIn(pagePath).toList())
-                       }
-                       
-                       canCommentAs.await() to comments.await()
-               }
-               
                val navbar = standardNavBar(pagePathParts)
                val sidebar = PageNavSidebar(pageNav)
                
@@ -66,5 +83,37 @@ suspend fun ApplicationCall.loreArticlePage(): HTML.() -> Unit {
                                        commentBox(comment, canCommentAs?.id)
                        }
                }
+       } else {
+               val title = pagePathParts.last().toFriendlyPageTitle()
+               
+               val navbar = standardNavBar(pagePathParts)
+               
+               val sidebar = PageNavSidebar(
+                       listOf(
+                               NavLink("#page-top", title, aClasses = "left"),
+                               NavLink("#comments", "Comments", aClasses = "left")
+                       )
+               )
+               
+               return page(title, navbar, sidebar) {
+                       section {
+                               a { id = "page-top" }
+                               h1 { +title }
+                               p {
+                                       +"This factbook does not exist. Would you like to "
+                                       a(href = "/") { +"return to the index page" }
+                                       +"?"
+                               }
+                       }
+                       section {
+                               h2 {
+                                       a { id = "comments" }
+                                       +"Comments"
+                               }
+                               commentInput(pagePath, canCommentAs)
+                               for (comment in comments)
+                                       commentBox(comment, canCommentAs?.id)
+                       }
+               }
        }
 }
index cd03f1aea782513ed81f04673c52791cf49b63c4..02558ed8e958281f4240f27d4593f1a8f70cf409 100644 (file)
@@ -13,7 +13,7 @@ suspend fun ApplicationCall.changeThemePage(): HTML.() -> Unit {
                else -> null
        }
        
-       return page("Client Preferences", standardNavBar(), null) {
+       return page("Client Preferences", standardNavBar()) {
                section {
                        h1 { +"Client Preferences" }
                        form(action = "/change-theme", method = FormMethod.post) {
index 002e495f8cec5ded33dfdef0d93fed5f9d0883c3..a57e786d445cc519d9a1abd55f7f9716238473d8 100644 (file)
@@ -505,6 +505,7 @@ main > section, main > nav.mobile, main > aside.mobile {
 aside.mobile img {
        margin: auto;
        display: block;
+       width: 50%;
 }
 
 @media only screen and (min-width: 8in) {
@@ -599,6 +600,10 @@ aside.mobile img {
                max-height: calc(92vh - 2.5rem);
        }
 
+       aside.desktop img {
+               width: 100%;
+       }
+
        aside.desktop div.list {
                max-height: calc(92vh - 7.5rem);
                overflow-y: auto;
@@ -873,6 +878,12 @@ textarea.lang-tylan {
        font-family: 'Noto Sans Gothic', sans-serif;
 }
 
+.flag-icon {
+       object-fit: cover;
+       aspect-ratio: 1;
+       border-radius: 50%;
+}
+
 .comment-input {
        border: 0.25em solid var(--comment-stroke);
        background-color: var(--comment-fill);
@@ -893,10 +904,7 @@ textarea.lang-tylan {
 }
 
 .comment-box > .comment-author > .flag-icon {
-       object-fit: cover;
        width: 2em;
-       height: 2em;
-       border-radius: 1em;
 
        flex-grow: 0;
        flex-shrink: 0;