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
}
}
}
+
+@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()
+ }
+ }
+}
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
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() }
}
}
}
+"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"
}
}
}
+ if (comment.replyLinks.isNotEmpty())
+ p {
+ style = "font-size:0.8em"
+ +"Replies:"
+ for (reply in comment.replyLinks) {
+ +" "
+ a(href = "/comment/view/$reply") {
+ +">>$reply"
+ }
+ }
+ }
}
}
if (limit == validLimit)
+"$validLimit"
else
- a(href="/comment/recent?limit=$validLimit") {
+ a(href = "/comment/recent?limit=$validLimit") {
+"$validLimit"
}
}
)
Comment.Table.put(comment)
+ CommentReplyLink.updateComment(comment.id, getReplies(contents))
redirect("/lore/$pagePath#comment-${comment.id}")
}
)
Comment.Table.put(newComment)
+ CommentReplyLink.updateComment(commentId, getReplies(newContents))
redirect("/comment/view/$commentId")
}
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")
}
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 {
--- /dev/null
+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()
+}
TH(TextParserFormattingTag.TH.type),
URL(TextParserFormattingTag.EXTLINK.type),
+ LANG(TextParserFormattingTag.LANG.type),
+
REPLY(
TextParserTagType.Indirect { _, content, _ ->
val id = sanitizeLink(content)
}
),
- LANG(TextParserFormattingTag.LANG.type)
+ QUOTE(
+ TextParserTagType.Direct({ _, _ ->
+ "<blockquote>"
+ }, { _ ->
+ "</blockquote>"
+ })
+ )
;
companion object {
}
}
-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, ".")
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 })
+ }
+}
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()
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")
.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 {
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;
}
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;
}