Add reply links to original comments
authorLanius Trolling <lanius@laniustrolling.dev>
Tue, 14 Feb 2023 15:40:12 +0000 (10:40 -0500)
committerLanius Trolling <lanius@laniustrolling.dev>
Tue, 14 Feb 2023 15:40:12 +0000 (10:40 -0500)
src/main/kotlin/info/mechyrdia/data/comments.kt
src/main/kotlin/info/mechyrdia/data/view_comments.kt
src/main/kotlin/info/mechyrdia/data/views_comment.kt
src/main/kotlin/info/mechyrdia/lore/parser_reply.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/lore/parser_tags.kt
src/main/kotlin/info/mechyrdia/lore/parser_toc.kt
src/main/kotlin/info/mechyrdia/lore/view_bar.kt
src/main/kotlin/info/mechyrdia/lore/view_nav.kt
src/main/resources/static/style.css

index c29ba71a679983f002cd909dfdafdf45b9f638ad..38397c9681b513398830ab0a752a29ee171018df 100644 (file)
@@ -1,6 +1,8 @@
 package info.mechyrdia.data
 
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.toSet
 import kotlinx.serialization.Contextual
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
@@ -39,3 +41,40 @@ data class Comment(
                }
        }
 }
+
+@Serializable
+data class CommentReplyLink(
+       @SerialName("_id")
+       override val id: Id<CommentReplyLink> = Id(),
+       
+       val originalPost: Id<Comment>,
+       val replyingPost: Id<Comment>
+) : DataDocument<CommentReplyLink> {
+       companion object : TableHolder<CommentReplyLink> {
+               override val Table: DocumentTable<CommentReplyLink> = DocumentTable()
+               
+               override suspend fun initialize() {
+                       Table.index(CommentReplyLink::originalPost)
+                       Table.index(CommentReplyLink::replyingPost)
+                       Table.unique(CommentReplyLink::originalPost, CommentReplyLink::replyingPost)
+               }
+               
+               suspend fun updateComment(updatedReply: Id<Comment>, repliesTo: Set<Id<Comment>>) {
+                       Table.remove(CommentReplyLink::replyingPost eq updatedReply)
+                       Table.put(
+                               repliesTo.map { original ->
+                                       CommentReplyLink(
+                                               originalPost = original,
+                                               replyingPost = updatedReply
+                                       )
+                               }
+                       )
+               }
+               
+               suspend fun deleteComment(deletedReply: Id<Comment>) = updateComment(deletedReply, emptySet())
+               
+               suspend fun getReplies(original: Id<Comment>): Set<Id<Comment>> {
+                       return Table.filter(CommentReplyLink::originalPost eq original).map { it.replyingPost }.toSet()
+               }
+       }
+}
index 320eb0fcb16445422ba31d1af9f2579afdeb1d4e..17402c12a27dd3acb02aba3b7c0ecb174dcc6162 100644 (file)
@@ -7,6 +7,8 @@ import info.mechyrdia.lore.TextParserCommentTags
 import info.mechyrdia.lore.TextParserState
 import info.mechyrdia.lore.dateTime
 import io.ktor.server.application.*
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
 import kotlinx.html.*
 import java.time.Instant
 
@@ -21,26 +23,33 @@ data class CommentRenderData(
        val lastEdit: Instant?,
        
        val contentsRaw: String,
-       val contentsHtml: String
+       val contentsHtml: String,
+       
+       val replyLinks: Set<Id<Comment>>,
 ) {
        companion object {
                suspend operator fun invoke(comments: List<Comment>, nations: MutableMap<Id<NationData>, NationData> = mutableMapOf()): List<CommentRenderData> {
-                       return comments.mapNotNull { comment ->
-                               val nationData = nations.getNation(comment.submittedBy)
-                               val htmlResult = TextParserState.parseText(comment.contents, TextParserCommentTags.asTags, Unit)
-                               
-                               if (htmlResult.succeeded)
-                                       CommentRenderData(
-                                               comment.id,
-                                               nationData,
-                                               comment.submittedIn,
-                                               comment.submittedAt,
-                                               comment.numEdits,
-                                               comment.lastEdit,
-                                               comment.contents,
-                                               htmlResult.html
-                                       )
-                               else null
+                       return coroutineScope {
+                               comments.mapNotNull { comment ->
+                                       val nationData = nations.getNation(comment.submittedBy)
+                                       val htmlResult = TextParserState.parseText(comment.contents, TextParserCommentTags.asTags, Unit)
+                                       
+                                       if (htmlResult.succeeded)
+                                               async {
+                                                       CommentRenderData(
+                                                               id = comment.id,
+                                                               submittedBy = nationData,
+                                                               submittedIn = comment.submittedIn,
+                                                               submittedAt = comment.submittedAt,
+                                                               numEdits = comment.numEdits,
+                                                               lastEdit = comment.lastEdit,
+                                                               contentsRaw = comment.contents,
+                                                               contentsHtml = htmlResult.html,
+                                                               replyLinks = CommentReplyLink.getReplies(comment.id),
+                                                       )
+                                               }
+                                       else null
+                               }.map { it.await() }
                        }
                }
        }
@@ -98,6 +107,14 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                                        +"Reply"
                                }
                                
+                               +Entities.nbsp
+                               +"\u2022"
+                               +Entities.nbsp
+                               a(href = "#", classes = "copy-text") {
+                                       attributes["data-text"] = "[quote]${comment.contentsRaw}[/quote][reply]${comment.id}[/reply]"
+                                       +"Quote Reply"
+                               }
+                               
                                if (loggedInAs == comment.submittedBy.id) {
                                        +Entities.nbsp
                                        +"\u2022"
@@ -117,6 +134,17 @@ fun FlowContent.commentBox(comment: CommentRenderData, loggedInAs: Id<NationData
                                        }
                                }
                        }
+                       if (comment.replyLinks.isNotEmpty())
+                               p {
+                                       style = "font-size:0.8em"
+                                       +"Replies:"
+                                       for (reply in comment.replyLinks) {
+                                               +" "
+                                               a(href = "/comment/view/$reply") {
+                                                       +">>$reply"
+                                               }
+                                       }
+                               }
                }
        }
        
index 573b4ea79596d76c23c1596251794d9fae7740d4..75edefff434ab45f3906910433090aa918c3134d 100644 (file)
@@ -45,7 +45,7 @@ suspend fun ApplicationCall.recentCommentsPage(): HTML.() -> Unit {
                                        if (limit == validLimit)
                                                +"$validLimit"
                                        else
-                                               a(href="/comment/recent?limit=$validLimit") {
+                                               a(href = "/comment/recent?limit=$validLimit") {
                                                        +"$validLimit"
                                                }
                                }
@@ -79,6 +79,7 @@ suspend fun ApplicationCall.newCommentRoute(): Nothing {
        )
        
        Comment.Table.put(comment)
+       CommentReplyLink.updateComment(comment.id, getReplies(contents))
        
        redirect("/lore/$pagePath#comment-${comment.id}")
 }
@@ -111,6 +112,7 @@ suspend fun ApplicationCall.editCommentRoute(): Nothing {
        )
        
        Comment.Table.put(newComment)
+       CommentReplyLink.updateComment(commentId, getReplies(newContents))
        
        redirect("/comment/view/$commentId")
 }
@@ -156,6 +158,7 @@ suspend fun ApplicationCall.deleteCommentRoute(): Nothing {
                throw ForbiddenException("Illegal attempt by ${currNation.id} to delete comment by ${comment.submittedBy}")
        
        Comment.Table.del(commentId)
+       CommentReplyLink.deleteComment(commentId)
        
        redirect("/lore/${comment.submittedIn}#comments")
 }
@@ -430,6 +433,15 @@ suspend fun ApplicationCall.commentHelpPage(): HTML.() -> Unit = page("Commentin
                                        td { +"[reply](comment id)[/reply]" }
                                        td { +"Creates a reply link to a comment" }
                                }
+                               tr {
+                                       td { +"[quote]Quoted text[/quote]" }
+                                       td {
+                                               +"Creates a "
+                                               blockQuote {
+                                                       +"block-level quote"
+                                               }
+                                       }
+                               }
                                tr {
                                        td { +"[lang=tylan]Rheagda Tulasra[/lang]" }
                                        td {
diff --git a/src/main/kotlin/info/mechyrdia/lore/parser_reply.kt b/src/main/kotlin/info/mechyrdia/lore/parser_reply.kt
new file mode 100644 (file)
index 0000000..1e59569
--- /dev/null
@@ -0,0 +1,37 @@
+package info.mechyrdia.lore
+
+import info.mechyrdia.data.Comment
+import info.mechyrdia.data.Id
+
+class CommentRepliesBuilder {
+       private val repliesTo = mutableSetOf<Id<Comment>>()
+       
+       fun addReplyTag(reply: Id<Comment>) {
+               repliesTo += reply
+       }
+       
+       fun toReplySet() = repliesTo.toSet()
+}
+
+enum class TextParserReplyCounterTag(val type: TextParserTagType<CommentRepliesBuilder>) {
+       REPLY(
+               TextParserTagType.Indirect { _, content, builder ->
+                       builder.addReplyTag(Id(sanitizeLink(content)))
+                       "[reply]$content[/reply]"
+               }
+       );
+       
+       companion object {
+               val asTags: TextParserTags<CommentRepliesBuilder>
+                       get() = TextParserTags(values().associate { it.name to it.type })
+       }
+}
+
+fun getReplies(commentContents: String): Set<Id<Comment>> {
+       val builder = CommentRepliesBuilder()
+       
+       if (!TextParserState.parseText(commentContents, TextParserReplyCounterTag.asTags, builder).succeeded)
+               return emptySet()
+       
+       return builder.toReplySet()
+}
index a83e8e9d6f527cb5d260941027009bf6dcb158d9..3fbfdf51c01c6d7893c79039c1f059f5d61e9c3c 100644 (file)
@@ -314,6 +314,8 @@ enum class TextParserCommentTags(val type: TextParserTagType<Unit>) {
        TH(TextParserFormattingTag.TH.type),
        URL(TextParserFormattingTag.EXTLINK.type),
        
+       LANG(TextParserFormattingTag.LANG.type),
+       
        REPLY(
                TextParserTagType.Indirect { _, content, _ ->
                        val id = sanitizeLink(content)
@@ -322,7 +324,13 @@ enum class TextParserCommentTags(val type: TextParserTagType<Unit>) {
                }
        ),
        
-       LANG(TextParserFormattingTag.LANG.type)
+       QUOTE(
+               TextParserTagType.Direct({ _, _ ->
+                       "<blockquote>"
+               }, { _ ->
+                       "</blockquote>"
+               })
+       )
        ;
        
        companion object {
@@ -331,50 +339,6 @@ enum class TextParserCommentTags(val type: TextParserTagType<Unit>) {
        }
 }
 
-enum class TextParserToCBuilderTag(val type: TextParserTagType<TableOfContentsBuilder>) {
-       H1(
-               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
-                       builder.add(headerContentToLabel(content), 0, headerContentToAnchor(content))
-                       "[h1]$content[/h1]"
-               }
-       ),
-       H2(
-               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
-                       builder.add(headerContentToLabel(content), 1, headerContentToAnchor(content))
-                       "[h2]$content[/h2]"
-               }
-       ),
-       H3(
-               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
-                       builder.add(headerContentToLabel(content), 2, headerContentToAnchor(content))
-                       "[h3]$content[/h3]"
-               }
-       ),
-       H4(
-               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
-                       builder.add(headerContentToLabel(content), 3, headerContentToAnchor(content))
-                       "[h4]$content[/h4]"
-               }
-       ),
-       H5(
-               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
-                       builder.add(headerContentToLabel(content), 4, headerContentToAnchor(content))
-                       "[h5]$content[/h5]"
-               }
-       ),
-       H6(
-               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
-                       builder.add(headerContentToLabel(content), 5, headerContentToAnchor(content))
-                       "[h6]$content[/h6]"
-               }
-       );
-       
-       companion object {
-               val asTags: TextParserTags<TableOfContentsBuilder>
-                       get() = TextParserTags(values().associate { it.name to it.type })
-       }
-}
-
 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, ".")
index 119b34d292ae94cb9e5b48f25d43f2f79dabf40d..b14ba199530158ce469a47d0e4ad03ec0ae706fe 100644 (file)
@@ -35,3 +35,47 @@ class TableOfContentsBuilder {
        
        fun toNavBar(): List<NavItem> = listOf(NavLink("#page-top", title!!, aClasses = "left")) + links.toList()
 }
+
+enum class TextParserToCBuilderTag(val type: TextParserTagType<TableOfContentsBuilder>) {
+       H1(
+               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
+                       builder.add(headerContentToLabel(content), 0, headerContentToAnchor(content))
+                       "[h1]$content[/h1]"
+               }
+       ),
+       H2(
+               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
+                       builder.add(headerContentToLabel(content), 1, headerContentToAnchor(content))
+                       "[h2]$content[/h2]"
+               }
+       ),
+       H3(
+               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
+                       builder.add(headerContentToLabel(content), 2, headerContentToAnchor(content))
+                       "[h3]$content[/h3]"
+               }
+       ),
+       H4(
+               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
+                       builder.add(headerContentToLabel(content), 3, headerContentToAnchor(content))
+                       "[h4]$content[/h4]"
+               }
+       ),
+       H5(
+               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
+                       builder.add(headerContentToLabel(content), 4, headerContentToAnchor(content))
+                       "[h5]$content[/h5]"
+               }
+       ),
+       H6(
+               TextParserTagType.Indirect<TableOfContentsBuilder> { _, content, builder ->
+                       builder.add(headerContentToLabel(content), 5, headerContentToAnchor(content))
+                       "[h6]$content[/h6]"
+               }
+       );
+       
+       companion object {
+               val asTags: TextParserTags<TableOfContentsBuilder>
+                       get() = TextParserTags(values().associate { it.name to it.type })
+       }
+}
index cb05709f1b732ee474282416ac2773d9a72f09e3..aba1c2e9685eacb3f635fbf47df02d8fbfb1928b 100644 (file)
@@ -1,6 +1,8 @@
 package info.mechyrdia.lore
 
-import kotlinx.html.*
+import kotlinx.html.ASIDE
+import kotlinx.html.TagConsumer
+import kotlinx.html.div
 
 abstract class Sidebar {
        protected abstract fun TagConsumer<*>.display()
index 03e17f51088706164c6fdac2cffe07ee27774763..f0763d01b1df96d035534f7c0735471d2dda250f 100644 (file)
@@ -8,21 +8,9 @@ import kotlinx.html.DIV
 import kotlinx.html.a
 import kotlinx.html.span
 import kotlinx.html.style
-import kotlin.collections.List
-import kotlin.collections.Map
 import kotlin.collections.component1
 import kotlin.collections.component2
-import kotlin.collections.dropLast
-import kotlin.collections.emptyMap
-import kotlin.collections.iterator
-import kotlin.collections.joinToString
-import kotlin.collections.listOf
-import kotlin.collections.mapIndexed
-import kotlin.collections.mapOf
-import kotlin.collections.orEmpty
-import kotlin.collections.plus
 import kotlin.collections.set
-import kotlin.collections.take
 
 suspend fun ApplicationCall.standardNavBar(path: List<String>? = null) = listOf(
        NavLink("/lore", "Lore Index")
index f18d467720bdcd499638cfb80be8a054d5e0ff39..a2d920597d24dadbb77dfad4b6fbf9c1cd3e79a7 100644 (file)
@@ -869,12 +869,13 @@ textarea.lang-tylan {
 .comment-box {
        border: 0.25em solid var(--comment-stroke);
        background-color: var(--comment-fill);
-       padding: 0.75em;
+       padding: 0.75em 0.5em 0.25em;
        margin: 1em 0;
 }
 
 .comment-box > .comment-author {
        display: flex;
+       align-items: center;
 }
 
 .comment-box > .comment-author > .flag-icon {
@@ -892,14 +893,12 @@ textarea.lang-tylan {
        font-weight: bold;
 
        text-align: left;
-       vertical-align: center;
        flex-grow: 1;
        flex-shrink: 0;
 }
 
 .comment-box > .comment-author > .posted-at {
        text-align: right;
-       vertical-align: center;
        flex-grow: 1;
        flex-shrink: 1;
 }
@@ -910,6 +909,13 @@ textarea.lang-tylan {
        padding-top: 0.5em;
 }
 
+blockquote {
+       margin: 0.25em 0;
+       border-left: 0.25em solid var(--comment-stroke);
+       padding: 0.5em;
+       background-color: rgba(0, 0, 0, 0.134);
+}
+
 .comment-edit-box {
        display: none;
 }