feat: ui rework
This commit is contained in:
parent
388dc63a12
commit
ec7e116a2b
20 changed files with 341 additions and 169 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
PORT=9000
|
APP_PORT=9000
|
||||||
HOST=localhost
|
APP_DOMAIN=localhost
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
DB_URL=jdbc:postgresql://localhost:5432/homepage
|
DB_URL=jdbc:postgresql://localhost:5432/homepage
|
||||||
DB_USERNAME=postgres
|
DB_USERNAME=postgres
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,23 +1,34 @@
|
||||||
@file:Suppress("ktlint")
|
@file:Suppress("ktlint")
|
||||||
package gg.jte.generated.precompiled
|
package gg.jte.generated.precompiled
|
||||||
import at.dokkae.homepage.templates.Index
|
import at.dokkae.homepage.templates.IndexTemplate
|
||||||
|
import at.dokkae.homepage.templates.MessageTemplate
|
||||||
|
import gg.jte.support.ForSupport
|
||||||
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
|
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
|
||||||
class JteIndexGenerated {
|
class JteIndexGenerated {
|
||||||
companion object {
|
companion object {
|
||||||
@JvmField val JTE_NAME = "Index.kte"
|
@JvmField val JTE_NAME = "Index.kte"
|
||||||
@JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,2,2,2,2,2,64,64,141,141,142,142,143,143,158,158,158,2,2,2,2,2)
|
@JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,4,4,4,4,4,18,18,37,53,73,78,83,83,84,84,85,85,91,97,108,121,133,148,148,148,4,4,4,4,4)
|
||||||
@JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:Index) {
|
@JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:IndexTemplate) {
|
||||||
jteOutput.writeContent("\n<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>Simple Chat — http4k + JTE + htmx</title>\n\n <script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js\" integrity=\"sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4\" integrity=\"sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N\" crossorigin=\"anonymous\"></script>\n\n <style>\n :root {\n --bg: #f5f5f7;\n --card: #ffffff;\n --border: #d0d0d5;\n --bubble-self: #daf0ff;\n --bubble-other: #ececec;\n --text-dark: #222;\n --text-light: #666;\n --radius: 12px;\n --spacing: 12px;\n --font: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n }\n\n body {\n margin: 0;\n font-family: var(--font);\n background: var(--bg);\n display: flex;\n justify-content: center;\n padding: 32px 12px;\n }\n\n #chat {\n width: 100%;\n max-width: 560px;\n background: var(--card);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 20px;\n box-shadow: 0 4px 20px rgba(0,0,0,0.05);\n }\n\n h1 {\n margin: 0 0 16px;\n font-size: 1.4rem;\n color: var(--text-dark);\n text-align: center;\n }\n\n #messages {\n display: flex;\n flex-direction: column-reverse;\n gap: var(--spacing);\n margin-bottom: 20px;\n max-height: 60vh;\n overflow-y: auto;\n padding-right: 4px;\n }\n\n ")
|
jteOutput.writeContent("\n<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>Dokkae's Chat</title>\n\n <script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js\" integrity=\"sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4\" integrity=\"sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4\"></script>\n\n <style>\n ")
|
||||||
jteOutput.writeContent("\n #messages::-webkit-scrollbar { width: 6px; }\n #messages::-webkit-scrollbar-thumb {\n background: #bbb;\n border-radius: 3px;\n }\n\n .message {\n padding: 10px 14px;\n border-radius: var(--radius);\n max-width: 85%;\n font-size: 0.95rem;\n line-height: 1.35;\n color: var(--text-dark);\n box-shadow: 0 1px 3px rgba(0,0,0,0.08);\n }\n\n .message-author {\n font-size: 0.8rem;\n font-weight: 600;\n margin-bottom: 4px;\n color: var(--text-light);\n }\n\n .message-self {\n align-self: flex-end;\n background: var(--bubble-self);\n }\n\n .message-other {\n align-self: flex-start;\n background: var(--bubble-other);\n }\n\n form {\n display: flex;\n gap: 8px;\n margin-bottom: 12px;\n }\n\n input[type=\"text\"] {\n flex: 1;\n padding: 10px;\n font-size: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n }\n\n button {\n padding: 10px 16px;\n background: #1e88ff;\n border: none;\n border-radius: var(--radius);\n color: white;\n font-size: 1rem;\n cursor: pointer;\n transition: 0.2s;\n }\n\n button:hover {\n background: #0c74e8;\n }\n\n p.note {\n font-size: 0.82rem;\n color: var(--text-light);\n margin-top: 14px;\n text-align: center;\n }\n </style>\n</head>\n<body hx-ext=\"sse\">\n<main id=\"chat\">\n <h1>Simple Chat</h1>\n\n <div id=\"messages\" sse-connect=\"/message-events\" sse-swap=\"message\" hx-swap=\"afterbegin\">\n ")
|
jteOutput.writeContent("\n .scrollbar-custom::-webkit-scrollbar {\n width: 8px;\n }\n\n .scrollbar-custom::-webkit-scrollbar-track {\n background: #1a1a1a;\n border-radius: 4px;\n }\n\n .scrollbar-custom::-webkit-scrollbar-thumb {\n background: #444;\n border-radius: 4px;\n }\n\n .scrollbar-custom::-webkit-scrollbar-thumb:hover {\n background: #555;\n }\n\n ")
|
||||||
|
jteOutput.writeContent("\n @keyframes slideIn {\n from {\n opacity: 0;\n transform: translateY(10px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n\n .animate-slide-in {\n animation: slideIn 0.3s ease-out;\n }\n\n ")
|
||||||
|
jteOutput.writeContent("\n .message-border-blue {\n background-color: #5865f2 !important;\n }\n\n .message-border-green {\n background-color: #57f287 !important;\n }\n\n .message-border-pink {\n background-color: #f57bf0 !important;\n }\n\n .message-border-orange {\n background-color: #faa81a !important;\n }\n </style>\n</head>\n<body hx-ext=\"sse\" class=\"bg-neutral-900 text-neutral-100 min-h-screen overflow-hidden\">\n<main class=\"flex flex-col h-screen max-w-6xl mx-auto px-4 md:px-6\">\n ")
|
||||||
|
jteOutput.writeContent("\n <header class=\"py-5 border-b border-neutral-800 shrink-0\">\n <h1 class=\"text-xl md:text-2xl font-bold text-white\">Simple Chat</h1>\n </header>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"flex-1 min-h-0 flex flex-col-reverse\">\n <div id=\"messages-container\" class=\"flex-1 overflow-y-auto overflow-x-hidden scrollbar-custom py-4\">\n <div class=\"mt-auto\">\n <div id=\"messages\" sse-connect=\"/message-events\" sse-swap=\"message\" hx-swap=\"afterbegin\">\n ")
|
||||||
for (message in model.messages.reversed()) {
|
for (message in model.messages.reversed()) {
|
||||||
jteOutput.writeContent("\n ")
|
jteOutput.writeContent("\n ")
|
||||||
gg.jte.generated.precompiled.partials.JteMessageGenerated.render(jteOutput, jteHtmlInterceptor, message);
|
gg.jte.generated.precompiled.partials.JteMessageGenerated.render(jteOutput, jteHtmlInterceptor, MessageTemplate(message));
|
||||||
jteOutput.writeContent("\n ")
|
jteOutput.writeContent("\n ")
|
||||||
}
|
}
|
||||||
jteOutput.writeContent("\n </div>\n\n\n <form hx-post=\"/messages\" hx-swap=\"none\" hx-on::after-request=\"if(event.detail.successful)document.getElementById('message-input').value = ''\">\n <input id=\"username-input\" type=\"text\" name=\"author\" placeholder=\"name (optional)\">\n <input id=\"message-input\" type=\"text\" name=\"message\" placeholder=\"your message\" required>\n <button type=\"submit\">\n Send\n </button>\n </form>\n\n <p style=\"font-size: .9rem; color: #666\">No auth — anyone can post. Messages are stored only in memory.</p>\n</main>\n</body>\n</html>")
|
jteOutput.writeContent("\n </div>\n </div>\n </div>\n </div>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <form class=\"bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 mb-4 mt-2 shrink-0\"\n hx-post=\"/messages\"\n hx-swap=\"none\"\n hx-on::after-request=\"if(event.detail.successful)document.getElementById('message-input').value = ''\">\n <div class=\"flex flex-col md:flex-row gap-3\">\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"flex-1 md:flex-none md:w-48\">\n <div class=\"relative\">\n <input id=\"username-input\"\n type=\"text\"\n name=\"author\"\n placeholder=\"Name (optional)\"\n class=\"w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition\">\n </div>\n </div>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"flex-1\">\n <div class=\"relative\">\n <input id=\"message-input\"\n type=\"text\"\n name=\"message\"\n placeholder=\"Your message...\"\n required\n autocomplete=\"off\"\n class=\"w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition\">\n </div>\n </div>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <button type=\"submit\"\n class=\"bg-rose-200 hover:bg-rose-300 text-black font-semibold py-3 px-6 rounded-lg transition duration-200 flex md:flex-1 items-center justify-center gap-2\">\n <svg width=\"24px\" height=\"24px\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <g id=\"Communication / Paper_Plane\">\n <path id=\"Vector\" d=\"M10.3078 13.6923L15.1539 8.84619M20.1113 5.88867L16.0207 19.1833C15.6541 20.3747 15.4706 20.9707 15.1544 21.1683C14.8802 21.3396 14.5406 21.3683 14.2419 21.2443C13.8975 21.1014 13.618 20.5433 13.0603 19.428L10.4694 14.2461C10.3809 14.0691 10.3366 13.981 10.2775 13.9043C10.225 13.8363 10.1645 13.7749 10.0965 13.7225C10.0215 13.6647 9.93486 13.6214 9.76577 13.5369L4.57192 10.9399C3.45662 10.3823 2.89892 10.1032 2.75601 9.75879C2.63207 9.4601 2.66033 9.12023 2.83169 8.84597C3.02928 8.52974 3.62523 8.34603 4.81704 7.97932L18.1116 3.88867C19.0486 3.60038 19.5173 3.45635 19.8337 3.57253C20.1094 3.67373 20.3267 3.89084 20.4279 4.16651C20.544 4.48283 20.3999 4.95126 20.1119 5.88729L20.1113 5.88867Z\" stroke=\"#000000\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n </g>\n </svg>\n </button>\n </div>\n </form>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <footer class=\"border-t border-neutral-800 py-4 shrink-0\">\n <p class=\"text-sm text-neutral-500 text-center\">\n No auth — anyone can post. Open source at\n <a href=\"https://github.com/dokkae6949/homepage\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"text-blue-400 hover:text-blue-300 hover:underline transition\">\n dokkae6949/homepage\n </a>\n </p>\n </footer>\n</main>\n\n</body>\n</html>")
|
||||||
}
|
}
|
||||||
@JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map<String, Any?>) {
|
@JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map<String, Any?>) {
|
||||||
val model = params["model"] as Index
|
val model = params["model"] as IndexTemplate
|
||||||
render(jteOutput, jteHtmlInterceptor, model);
|
render(jteOutput, jteHtmlInterceptor, model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,26 +1,46 @@
|
||||||
@file:Suppress("ktlint")
|
@file:Suppress("ktlint")
|
||||||
package gg.jte.generated.precompiled.partials
|
package gg.jte.generated.precompiled.partials
|
||||||
import at.dokkae.homepage.Message
|
import at.dokkae.homepage.templates.MessageTemplate
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
|
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
|
||||||
class JteMessageGenerated {
|
class JteMessageGenerated {
|
||||||
companion object {
|
companion object {
|
||||||
@JvmField val JTE_NAME = "partials/Message.kte"
|
@JvmField val JTE_NAME = "partials/Message.kte"
|
||||||
@JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,2,2,2,2,2,5,5,5,5,6,6,6,8,8,8,10,10,10,2,2,2,2,2)
|
@JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,3,4,6,6,6,6,6,8,8,8,9,9,10,10,14,15,15,15,15,18,21,21,21,24,24,24,24,24,24,28,30,30,30,34,34,34,6,6,6,6,6)
|
||||||
@JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, message:Message) {
|
@JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:MessageTemplate) {
|
||||||
jteOutput.writeContent("\n<div class=\"message\">\n <strong>")
|
jteOutput.writeContent("\n")
|
||||||
jteOutput.setContext("strong", null)
|
val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(ZoneId.systemDefault())
|
||||||
jteOutput.writeUserContent(message.author)
|
jteOutput.writeContent("\n")
|
||||||
jteOutput.writeContent("</strong>:\n ")
|
val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
|
||||||
|
jteOutput.writeContent("\n")
|
||||||
|
val borderColors = listOf("blue", "green", "pink", "orange")
|
||||||
|
jteOutput.writeContent("\n\n<div class=\"message-group mb-3 animate-slide-in\">\n <div class=\"flex relative px-3 py-1 hover:bg-neutral-800/30 rounded transition-colors\">\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3/4 rounded-r message-border-")
|
||||||
|
jteOutput.setContext("div", "class")
|
||||||
|
jteOutput.writeUserContent(borderColors[model.message.id.hashCode().absoluteValue % borderColors.size])
|
||||||
jteOutput.setContext("div", null)
|
jteOutput.setContext("div", null)
|
||||||
jteOutput.writeUserContent(message.content)
|
jteOutput.writeContent("\"></div>\n\n <div class=\"flex-1 pl-3 text-ellipsis text-wrap break-all\">\n ")
|
||||||
jteOutput.writeContent("\n <span style=\"color:#888; font-size:.8rem;\">\n (")
|
jteOutput.writeContent("\n <div class=\"flex flex-wrap items-baseline gap-2 mb-1\">\n <span class=\"font-semibold text-white\">\n ")
|
||||||
jteOutput.setContext("span", null)
|
jteOutput.setContext("span", null)
|
||||||
jteOutput.writeUserContent(message.createdAt.toString())
|
jteOutput.writeUserContent(model.message.author)
|
||||||
jteOutput.writeContent(")\n </span>\n</div>")
|
jteOutput.writeContent("\n </span>\n <span class=\"text-xs text-neutral-400\">\n ")
|
||||||
|
jteOutput.setContext("span", null)
|
||||||
|
jteOutput.writeUserContent(dateFormatter.format(model.message.createdAt))
|
||||||
|
jteOutput.writeContent(" • ")
|
||||||
|
jteOutput.setContext("span", null)
|
||||||
|
jteOutput.writeUserContent(timeFormatter.format(model.message.createdAt))
|
||||||
|
jteOutput.writeContent("\n </span>\n </div>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"text-neutral-200\">\n ")
|
||||||
|
jteOutput.setContext("div", null)
|
||||||
|
jteOutput.writeUserContent(model.message.content)
|
||||||
|
jteOutput.writeContent("\n </div>\n </div>\n </div>\n</div>")
|
||||||
}
|
}
|
||||||
@JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map<String, Any?>) {
|
@JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map<String, Any?>) {
|
||||||
val message = params["message"] as Message
|
val model = params["model"] as MessageTemplate
|
||||||
render(jteOutput, jteHtmlInterceptor, message);
|
render(jteOutput, jteHtmlInterceptor, model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
jte-classes/META-INF/main.kotlin_module
Normal file
BIN
jte-classes/META-INF/main.kotlin_module
Normal file
Binary file not shown.
35
jte-classes/gg/jte/generated/ondemand/JteIndexGenerated.kt
Normal file
35
jte-classes/gg/jte/generated/ondemand/JteIndexGenerated.kt
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
@file:Suppress("ktlint")
|
||||||
|
package gg.jte.generated.ondemand
|
||||||
|
import at.dokkae.homepage.templates.IndexTemplate
|
||||||
|
import at.dokkae.homepage.templates.MessageTemplate
|
||||||
|
import gg.jte.support.ForSupport
|
||||||
|
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
|
||||||
|
class JteIndexGenerated {
|
||||||
|
companion object {
|
||||||
|
@JvmField val JTE_NAME = "Index.kte"
|
||||||
|
@JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,4,4,4,4,4,18,18,37,58,88,93,96,96,97,97,98,98,102,108,119,132,146,161,161,161,4,4,4,4,4)
|
||||||
|
@JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:IndexTemplate) {
|
||||||
|
jteOutput.writeContent("\n<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>Dokkae's Chat</title>\n\n <script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js\" integrity=\"sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4\" integrity=\"sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4\"></script>\n\n <style>\n ")
|
||||||
|
jteOutput.writeContent("\n .scrollbar-custom::-webkit-scrollbar {\n width: 8px;\n }\n\n .scrollbar-custom::-webkit-scrollbar-track {\n background: #1a1a1a;\n border-radius: 4px;\n }\n\n .scrollbar-custom::-webkit-scrollbar-thumb {\n background: #444;\n border-radius: 4px;\n }\n\n .scrollbar-custom::-webkit-scrollbar-thumb:hover {\n background: #555;\n }\n\n ")
|
||||||
|
jteOutput.writeContent("\n @keyframes slideIn {\n from {\n opacity: 0;\n transform: translateY(20px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n\n .animate-slide-in.htmx-added {\n opacity: 0;\n }\n\n .animate-slide-in {\n opacity: 1;\n animation: slideIn 0.3s ease-out;\n }\n\n ")
|
||||||
|
jteOutput.writeContent("\n .message-border-red {\n background-color: #ffb3ba !important;\n }\n\n .message-border-orange {\n background-color: #ffdfba !important;\n }\n\n .message-border-yellow {\n background-color: #ffffba !important;\n }\n\n .message-border-green {\n background-color: #baffc9 !important;\n }\n\n .message-border-blue {\n background-color: #bae1ff !important;\n }\n\n .message-border-pink {\n background-color: #fddfdf !important;\n }\n\n\n </style>\n</head>\n<body hx-ext=\"sse\" class=\"bg-neutral-900 text-neutral-100 min-h-screen overflow-hidden\">\n<main class=\"flex flex-col h-screen max-w-6xl mx-auto px-4 md:px-6\">\n ")
|
||||||
|
jteOutput.writeContent("\n <header class=\"py-5 border-b border-neutral-800 shrink-0\">\n <h1 class=\"text-xl md:text-2xl font-bold text-white\">Dokkae's Chat</h1>\n </header>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <div id=\"messages-container\" class=\"flex-1 flex flex-col-reverse overflow-y-auto overflow-x-hidden scrollbar-custom py-4\">\n <div id=\"messages\" class=\"flex flex-col-reverse\" sse-connect=\"/message-events\" sse-swap=\"message\" hx-swap=\"afterbegin\">\n ")
|
||||||
|
for (message in model.messages) {
|
||||||
|
jteOutput.writeContent("\n ")
|
||||||
|
gg.jte.generated.ondemand.partials.JteMessageGenerated.render(jteOutput, jteHtmlInterceptor, MessageTemplate(message));
|
||||||
|
jteOutput.writeContent("\n ")
|
||||||
|
}
|
||||||
|
jteOutput.writeContent("\n </div>\n </div>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <form class=\"bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 mb-4 mt-2 shrink-0\"\n hx-post=\"/messages\"\n hx-swap=\"none\"\n hx-on::after-request=\"if(event.detail.successful)document.getElementById('message-input').value = ''\">\n <div class=\"flex flex-col md:flex-row gap-3\">\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"flex-1 md:flex-none md:w-48\">\n <div class=\"relative\">\n <input id=\"username-input\"\n type=\"text\"\n name=\"author\"\n placeholder=\"Name (optional)\"\n class=\"w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition\">\n </div>\n </div>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"flex-1\">\n <div class=\"flex flex-row gap-3\">\n <div class=\"relative flex-1\">\n <input id=\"message-input\"\n type=\"text\"\n name=\"message\"\n placeholder=\"Your message...\"\n required\n autocomplete=\"off\"\n class=\"w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition\">\n </div>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <button type=\"submit\"\n class=\"bg-rose-200 hover:bg-rose-300 text-black font-semibold py-3 px-6 rounded-lg transition duration-200 flex items-center justify-center gap-2\">\n <svg width=\"24px\" height=\"24px\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <g id=\"Communication / Paper_Plane\">\n <path id=\"Vector\" d=\"M10.3078 13.6923L15.1539 8.84619M20.1113 5.88867L16.0207 19.1833C15.6541 20.3747 15.4706 20.9707 15.1544 21.1683C14.8802 21.3396 14.5406 21.3683 14.2419 21.2443C13.8975 21.1014 13.618 20.5433 13.0603 19.428L10.4694 14.2461C10.3809 14.0691 10.3366 13.981 10.2775 13.9043C10.225 13.8363 10.1645 13.7749 10.0965 13.7225C10.0215 13.6647 9.93486 13.6214 9.76577 13.5369L4.57192 10.9399C3.45662 10.3823 2.89892 10.1032 2.75601 9.75879C2.63207 9.4601 2.66033 9.12023 2.83169 8.84597C3.02928 8.52974 3.62523 8.34603 4.81704 7.97932L18.1116 3.88867C19.0486 3.60038 19.5173 3.45635 19.8337 3.57253C20.1094 3.67373 20.3267 3.89084 20.4279 4.16651C20.544 4.48283 20.3999 4.95126 20.1119 5.88729L20.1113 5.88867Z\" stroke=\"#000000\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n </g>\n </svg>\n </button>\n </div>\n </div>\n </div>\n </form>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <footer class=\"border-t border-neutral-800 py-4 shrink-0\">\n <p class=\"text-sm text-neutral-500 text-center\">\n No auth — anyone can post. Open source at\n <a href=\"https://github.com/dokkae6949/homepage\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"text-blue-400 hover:text-blue-300 hover:underline transition\">\n dokkae6949/homepage\n </a>\n </p>\n </footer>\n</main>\n\n</body>\n</html>")
|
||||||
|
}
|
||||||
|
@JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map<String, Any?>) {
|
||||||
|
val model = params["model"] as IndexTemplate
|
||||||
|
render(jteOutput, jteHtmlInterceptor, model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
@file:Suppress("ktlint")
|
||||||
|
package gg.jte.generated.ondemand.partials
|
||||||
|
import at.dokkae.homepage.templates.MessageTemplate
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
|
||||||
|
class JteMessageGenerated {
|
||||||
|
companion object {
|
||||||
|
@JvmField val JTE_NAME = "partials/Message.kte"
|
||||||
|
@JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,3,4,6,6,6,6,6,8,8,8,9,9,10,10,14,15,15,15,15,18,21,21,21,24,24,24,24,24,24,28,30,30,30,34,34,34,6,6,6,6,6)
|
||||||
|
@JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:MessageTemplate) {
|
||||||
|
jteOutput.writeContent("\n")
|
||||||
|
val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(ZoneId.systemDefault())
|
||||||
|
jteOutput.writeContent("\n")
|
||||||
|
val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
|
||||||
|
jteOutput.writeContent("\n")
|
||||||
|
val borderColors = listOf("red", "orange", "yellow", "green", "blue", "pink" )
|
||||||
|
jteOutput.writeContent("\n\n<div class=\"message-group mb-3 animate-slide-in\">\n <div class=\"flex relative px-3 py-1 hover:bg-neutral-800/30 rounded transition-colors\">\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3/4 rounded-r message-border-")
|
||||||
|
jteOutput.setContext("div", "class")
|
||||||
|
jteOutput.writeUserContent(borderColors[model.message.id.hashCode().absoluteValue % borderColors.size])
|
||||||
|
jteOutput.setContext("div", null)
|
||||||
|
jteOutput.writeContent("\"></div>\n\n <div class=\"flex-1 pl-3 text-ellipsis text-wrap break-all\">\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"flex flex-wrap items-baseline gap-2 mb-1\">\n <span class=\"font-semibold text-white\">\n ")
|
||||||
|
jteOutput.setContext("span", null)
|
||||||
|
jteOutput.writeUserContent(model.message.author)
|
||||||
|
jteOutput.writeContent("\n </span>\n <span class=\"text-xs text-neutral-400\">\n ")
|
||||||
|
jteOutput.setContext("span", null)
|
||||||
|
jteOutput.writeUserContent(dateFormatter.format(model.message.createdAt))
|
||||||
|
jteOutput.writeContent(" • ")
|
||||||
|
jteOutput.setContext("span", null)
|
||||||
|
jteOutput.writeUserContent(timeFormatter.format(model.message.createdAt))
|
||||||
|
jteOutput.writeContent("\n </span>\n </div>\n\n ")
|
||||||
|
jteOutput.writeContent("\n <div class=\"text-neutral-200\">\n ")
|
||||||
|
jteOutput.setContext("div", null)
|
||||||
|
jteOutput.writeUserContent(model.message.content)
|
||||||
|
jteOutput.writeContent("\n </div>\n </div>\n </div>\n</div>")
|
||||||
|
}
|
||||||
|
@JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map<String, Any?>) {
|
||||||
|
val model = params["model"] as MessageTemplate
|
||||||
|
render(jteOutput, jteHtmlInterceptor, model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package at.dokkae.homepage
|
package at.dokkae.homepage
|
||||||
|
|
||||||
|
import at.dokkae.homepage.config.Env
|
||||||
import at.dokkae.homepage.config.Environment
|
import at.dokkae.homepage.config.Environment
|
||||||
import at.dokkae.homepage.extensions.Precompiled
|
import at.dokkae.homepage.extensions.Precompiled
|
||||||
import at.dokkae.homepage.repository.MessageRepository
|
import at.dokkae.homepage.repository.MessageRepository
|
||||||
import at.dokkae.homepage.repository.impls.JooqMessageRepository
|
import at.dokkae.homepage.repository.impls.JooqMessageRepository
|
||||||
import at.dokkae.homepage.templates.Index
|
import at.dokkae.homepage.templates.IndexTemplate
|
||||||
|
import at.dokkae.homepage.templates.MessageTemplate
|
||||||
import io.github.cdimascio.dotenv.dotenv
|
import io.github.cdimascio.dotenv.dotenv
|
||||||
import org.flywaydb.core.Flyway
|
import org.flywaydb.core.Flyway
|
||||||
import org.http4k.core.HttpHandler
|
import org.http4k.core.HttpHandler
|
||||||
|
|
@ -14,18 +16,19 @@ import org.http4k.core.Status
|
||||||
import org.http4k.core.body.form
|
import org.http4k.core.body.form
|
||||||
import org.http4k.core.getFirst
|
import org.http4k.core.getFirst
|
||||||
import org.http4k.core.toParametersMap
|
import org.http4k.core.toParametersMap
|
||||||
|
import org.http4k.routing.ResourceLoader
|
||||||
import org.http4k.routing.bindHttp
|
import org.http4k.routing.bindHttp
|
||||||
import org.http4k.routing.bindSse
|
import org.http4k.routing.bindSse
|
||||||
import org.http4k.routing.poly
|
import org.http4k.routing.poly
|
||||||
import org.http4k.routing.routes
|
import org.http4k.routing.routes
|
||||||
import org.http4k.routing.sse
|
import org.http4k.routing.sse
|
||||||
|
import org.http4k.routing.static
|
||||||
import org.http4k.server.Jetty
|
import org.http4k.server.Jetty
|
||||||
import org.http4k.server.asServer
|
import org.http4k.server.asServer
|
||||||
import org.http4k.sse.Sse
|
import org.http4k.sse.Sse
|
||||||
import org.http4k.sse.SseMessage
|
import org.http4k.sse.SseMessage
|
||||||
import org.http4k.sse.SseResponse
|
import org.http4k.sse.SseResponse
|
||||||
import org.http4k.template.JTETemplates
|
import org.http4k.template.JTETemplates
|
||||||
import org.http4k.template.ViewModel
|
|
||||||
import org.jooq.SQLDialect
|
import org.jooq.SQLDialect
|
||||||
import org.jooq.impl.DSL
|
import org.jooq.impl.DSL
|
||||||
import java.sql.DriverManager
|
import java.sql.DriverManager
|
||||||
|
|
@ -52,14 +55,11 @@ data class Message(
|
||||||
val id: UUID = UUID.randomUUID(),
|
val id: UUID = UUID.randomUUID(),
|
||||||
val createdAt: Instant = Instant.now(),
|
val createdAt: Instant = Instant.now(),
|
||||||
val updatedAt: Instant? = null
|
val updatedAt: Instant? = null
|
||||||
) : ViewModel {
|
) {
|
||||||
init {
|
init {
|
||||||
require(author.length <= 31) { "Author must be 31 characters or less" }
|
require(author.length <= 31) { "Author must be 31 characters or less" }
|
||||||
require(content.length <= 255) { "Content must be 255 characters or less" }
|
require(content.length <= 255) { "Content must be 255 characters or less" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun template(): String = "partials/Message"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun main() {
|
fun main() {
|
||||||
|
|
@ -77,10 +77,19 @@ fun main() {
|
||||||
val messageRepository: MessageRepository = JooqMessageRepository(dslContext)
|
val messageRepository: MessageRepository = JooqMessageRepository(dslContext)
|
||||||
|
|
||||||
val subscribers = CopyOnWriteArrayList<Sse>()
|
val subscribers = CopyOnWriteArrayList<Sse>()
|
||||||
val renderer = JTETemplates().Precompiled("build/generated-resources/jte")
|
val renderer = when (env.appEnv) {
|
||||||
|
Env.DEVELOPMENT -> {
|
||||||
|
println("🔥 Hot-Reloading JTE templates")
|
||||||
|
JTETemplates().HotReload("src/main/kte")
|
||||||
|
}
|
||||||
|
Env.PRODUCTION -> {
|
||||||
|
println("📦 Loading pre-compiled JTE templates")
|
||||||
|
JTETemplates().Precompiled("build/generated-resources/jte")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val indexHandler: HttpHandler = {
|
val indexHandler: HttpHandler = {
|
||||||
Response(Status.OK).body(renderer(Index(messageRepository.findAll())))
|
Response(Status.OK).body(renderer(IndexTemplate(messageRepository.findAll())))
|
||||||
}
|
}
|
||||||
|
|
||||||
val sse = sse(
|
val sse = sse(
|
||||||
|
|
@ -94,9 +103,12 @@ fun main() {
|
||||||
)
|
)
|
||||||
|
|
||||||
val http = routes(
|
val http = routes(
|
||||||
|
static(ResourceLoader.Classpath("static")),
|
||||||
|
|
||||||
"/" bindHttp GET to indexHandler,
|
"/" bindHttp GET to indexHandler,
|
||||||
|
|
||||||
"/messages" bindHttp POST to { req ->
|
"/messages" bindHttp POST to { req ->
|
||||||
|
try {
|
||||||
val params = req.form().toParametersMap()
|
val params = req.form().toParametersMap()
|
||||||
val author = params.getFirst("author").takeIf { !it.isNullOrBlank() } ?: "Anonymous"
|
val author = params.getFirst("author").takeIf { !it.isNullOrBlank() } ?: "Anonymous"
|
||||||
val message = params.getFirst("message")
|
val message = params.getFirst("message")
|
||||||
|
|
@ -105,7 +117,7 @@ fun main() {
|
||||||
Response(Status.BAD_REQUEST)
|
Response(Status.BAD_REQUEST)
|
||||||
} else {
|
} else {
|
||||||
val msg = Message(author, message)
|
val msg = Message(author, message)
|
||||||
val sseMsg = SseMessage.Data(renderer(msg))
|
val sseMsg = SseMessage.Data(renderer(MessageTemplate(msg)))
|
||||||
|
|
||||||
messageRepository.save(msg)
|
messageRepository.save(msg)
|
||||||
subscribers.forEach {
|
subscribers.forEach {
|
||||||
|
|
@ -114,10 +126,15 @@ fun main() {
|
||||||
|
|
||||||
Response(Status.CREATED)
|
Response(Status.CREATED)
|
||||||
}
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
println("Failed to receive message: ${ex.toString()} ${ex.message}")
|
||||||
|
|
||||||
|
Response(Status.INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
poly(http, sse).asServer(Jetty(port = env.port)).start()
|
poly(http, sse).asServer(Jetty(port = env.appPort)).start()
|
||||||
|
|
||||||
println("Server started on http://${env.host}:${env.port}")
|
println("Server started on http://${env.appDomain}:${env.appPort}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,15 @@ package at.dokkae.homepage.config
|
||||||
|
|
||||||
import io.github.cdimascio.dotenv.Dotenv
|
import io.github.cdimascio.dotenv.Dotenv
|
||||||
|
|
||||||
|
enum class Env {
|
||||||
|
DEVELOPMENT,
|
||||||
|
PRODUCTION,
|
||||||
|
}
|
||||||
|
|
||||||
data class Environment(
|
data class Environment(
|
||||||
val port: Int,
|
val appPort: Int,
|
||||||
val host: String,
|
val appDomain: String,
|
||||||
|
val appEnv: Env,
|
||||||
val dbUrl: String,
|
val dbUrl: String,
|
||||||
val dbUsername: String,
|
val dbUsername: String,
|
||||||
val dbPassword: String,
|
val dbPassword: String,
|
||||||
|
|
@ -16,8 +22,9 @@ data class Environment(
|
||||||
* @throws IllegalStateException if required environment variables were not found within the provided `dotenv` instance.
|
* @throws IllegalStateException if required environment variables were not found within the provided `dotenv` instance.
|
||||||
*/
|
*/
|
||||||
fun load(dotenv: Dotenv): Environment = Environment(
|
fun load(dotenv: Dotenv): Environment = Environment(
|
||||||
port = requireEnv(dotenv, "PORT").toInt(),
|
appPort = requireEnv(dotenv, "APP_PORT").toInt(),
|
||||||
host = requireEnv(dotenv, "HOST"),
|
appDomain = requireEnv(dotenv, "APP_DOMAIN"),
|
||||||
|
appEnv = Env.valueOf(requireEnv(dotenv, "APP_ENV").uppercase()),
|
||||||
dbUrl = requireEnv(dotenv, "DB_URL"),
|
dbUrl = requireEnv(dotenv, "DB_URL"),
|
||||||
dbUsername = requireEnv(dotenv, "DB_USERNAME"),
|
dbUsername = requireEnv(dotenv, "DB_USERNAME"),
|
||||||
dbPassword = requireEnv(dotenv, "DB_PASSWORD"),
|
dbPassword = requireEnv(dotenv, "DB_PASSWORD"),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@ package at.dokkae.homepage.templates
|
||||||
import at.dokkae.homepage.Message
|
import at.dokkae.homepage.Message
|
||||||
import org.http4k.template.ViewModel
|
import org.http4k.template.ViewModel
|
||||||
|
|
||||||
data class Index(val messages: List<Message> = listOf()) : ViewModel {
|
data class IndexTemplate(val messages: List<Message> = listOf()) : ViewModel {
|
||||||
override fun template(): String = "Index"
|
override fun template(): String = "Index"
|
||||||
}
|
}
|
||||||
8
src/main/kotlin/at/dokkae/homepage/templates/Message.kt
Normal file
8
src/main/kotlin/at/dokkae/homepage/templates/Message.kt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
package at.dokkae.homepage.templates
|
||||||
|
|
||||||
|
import at.dokkae.homepage.Message
|
||||||
|
import org.http4k.template.ViewModel
|
||||||
|
|
||||||
|
data class MessageTemplate(val message: Message) : ViewModel {
|
||||||
|
override fun template(): String = "partials/Message"
|
||||||
|
}
|
||||||
|
|
@ -1,159 +1,162 @@
|
||||||
@import at.dokkae.homepage.templates.Index
|
@import at.dokkae.homepage.templates.IndexTemplate
|
||||||
|
@import at.dokkae.homepage.templates.MessageTemplate
|
||||||
|
@import gg.jte.support.ForSupport
|
||||||
|
|
||||||
@param model: Index
|
@param model: IndexTemplate
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Simple Chat — http4k + JTE + htmx</title>
|
<title>Dokkae's Chat</title>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4" integrity="sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4" integrity="sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
/* Custom scrollbar */
|
||||||
--bg: #f5f5f7;
|
.scrollbar-custom::-webkit-scrollbar {
|
||||||
--card: #ffffff;
|
width: 8px;
|
||||||
--border: #d0d0d5;
|
|
||||||
--bubble-self: #daf0ff;
|
|
||||||
--bubble-other: #ececec;
|
|
||||||
--text-dark: #222;
|
|
||||||
--text-light: #666;
|
|
||||||
--radius: 12px;
|
|
||||||
--spacing: 12px;
|
|
||||||
--font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.scrollbar-custom::-webkit-scrollbar-track {
|
||||||
margin: 0;
|
background: #1a1a1a;
|
||||||
font-family: var(--font);
|
border-radius: 4px;
|
||||||
background: var(--bg);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 32px 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#chat {
|
.scrollbar-custom::-webkit-scrollbar-thumb {
|
||||||
width: 100%;
|
background: #444;
|
||||||
max-width: 560px;
|
border-radius: 4px;
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
|
||||||
margin: 0 0 16px;
|
background: #555;
|
||||||
font-size: 1.4rem;
|
|
||||||
color: var(--text-dark);
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#messages {
|
/* Animation for new messages */
|
||||||
display: flex;
|
@keyframes slideIn {
|
||||||
flex-direction: column-reverse;
|
from {
|
||||||
gap: var(--spacing);
|
opacity: 0;
|
||||||
margin-bottom: 20px;
|
transform: translateY(20px);
|
||||||
max-height: 60vh;
|
}
|
||||||
overflow-y: auto;
|
to {
|
||||||
padding-right: 4px;
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nice scrollbar */
|
.animate-slide-in.htmx-added {
|
||||||
#messages::-webkit-scrollbar { width: 6px; }
|
opacity: 0;
|
||||||
#messages::-webkit-scrollbar-thumb {
|
|
||||||
background: #bbb;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.animate-slide-in {
|
||||||
padding: 10px 14px;
|
opacity: 1;
|
||||||
border-radius: var(--radius);
|
animation: slideIn 0.3s ease-out;
|
||||||
max-width: 85%;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
color: var(--text-dark);
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-author {
|
/* Different border colors */
|
||||||
font-size: 0.8rem;
|
.message-border-red {
|
||||||
font-weight: 600;
|
background-color: #ffb3ba !important;
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--text-light);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-self {
|
.message-border-orange {
|
||||||
align-self: flex-end;
|
background-color: #ffdfba !important;
|
||||||
background: var(--bubble-self);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-other {
|
.message-border-yellow {
|
||||||
align-self: flex-start;
|
background-color: #ffffba !important;
|
||||||
background: var(--bubble-other);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
.message-border-green {
|
||||||
display: flex;
|
background-color: #baffc9 !important;
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
.message-border-blue {
|
||||||
flex: 1;
|
background-color: #bae1ff !important;
|
||||||
padding: 10px;
|
|
||||||
font-size: 1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.message-border-pink {
|
||||||
padding: 10px 16px;
|
background-color: #fddfdf !important;
|
||||||
background: #1e88ff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: white;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background: #0c74e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.note {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-light);
|
|
||||||
margin-top: 14px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body hx-ext="sse">
|
<body hx-ext="sse" class="bg-neutral-900 text-neutral-100 min-h-screen overflow-hidden">
|
||||||
<main id="chat">
|
<main class="flex flex-col h-screen max-w-6xl mx-auto px-4 md:px-6">
|
||||||
<h1>Simple Chat</h1>
|
<!-- Header -->
|
||||||
|
<header class="py-5 border-b border-neutral-800 shrink-0">
|
||||||
|
<h1 class="text-xl md:text-2xl font-bold text-white">Dokkae's Chat</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div id="messages" sse-connect="/message-events" sse-swap="message" hx-swap="afterbegin">
|
<!-- Messages Container - CSS-only scroll to bottom -->
|
||||||
@for (message in model.messages.reversed())
|
<div id="messages-container" class="flex-1 flex flex-col-reverse overflow-y-auto overflow-x-hidden scrollbar-custom py-4">
|
||||||
@template.partials.Message(message)
|
<div id="messages" class="flex flex-col-reverse" sse-connect="/message-events" sse-swap="message" hx-swap="afterbegin">
|
||||||
|
@for (message in model.messages)
|
||||||
|
@template.partials.Message(MessageTemplate(message))
|
||||||
@endfor
|
@endfor
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Form -->
|
||||||
|
<form class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 mb-4 mt-2 shrink-0"
|
||||||
|
hx-post="/messages"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful)document.getElementById('message-input').value = ''">
|
||||||
|
<div class="flex flex-col md:flex-row gap-3">
|
||||||
|
<!-- Username input -->
|
||||||
|
<div class="flex-1 md:flex-none md:w-48">
|
||||||
|
<div class="relative">
|
||||||
|
<input id="username-input"
|
||||||
|
type="text"
|
||||||
|
name="author"
|
||||||
|
placeholder="Name (optional)"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form hx-post="/messages" hx-swap="none" hx-on::after-request="if(event.detail.successful)document.getElementById('message-input').value = ''">
|
<!-- Message input -->
|
||||||
<input id="username-input" type="text" name="author" placeholder="name (optional)">
|
<div class="flex-1">
|
||||||
<input id="message-input" type="text" name="message" placeholder="your message" required>
|
<div class="flex flex-row gap-3">
|
||||||
<button type="submit">
|
<div class="relative flex-1">
|
||||||
Send
|
<input id="message-input"
|
||||||
|
type="text"
|
||||||
|
name="message"
|
||||||
|
placeholder="Your message..."
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Send button -->
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-rose-200 hover:bg-rose-300 text-black font-semibold py-3 px-6 rounded-lg transition duration-200 flex items-center justify-center gap-2">
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="Communication / Paper_Plane">
|
||||||
|
<path id="Vector" d="M10.3078 13.6923L15.1539 8.84619M20.1113 5.88867L16.0207 19.1833C15.6541 20.3747 15.4706 20.9707 15.1544 21.1683C14.8802 21.3396 14.5406 21.3683 14.2419 21.2443C13.8975 21.1014 13.618 20.5433 13.0603 19.428L10.4694 14.2461C10.3809 14.0691 10.3366 13.981 10.2775 13.9043C10.225 13.8363 10.1645 13.7749 10.0965 13.7225C10.0215 13.6647 9.93486 13.6214 9.76577 13.5369L4.57192 10.9399C3.45662 10.3823 2.89892 10.1032 2.75601 9.75879C2.63207 9.4601 2.66033 9.12023 2.83169 8.84597C3.02928 8.52974 3.62523 8.34603 4.81704 7.97932L18.1116 3.88867C19.0486 3.60038 19.5173 3.45635 19.8337 3.57253C20.1094 3.67373 20.3267 3.89084 20.4279 4.16651C20.544 4.48283 20.3999 4.95126 20.1119 5.88729L20.1113 5.88867Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p style="font-size: .9rem; color: #666">No auth — anyone can post. Messages are stored only in memory.</p>
|
<!-- Footer -->
|
||||||
|
<footer class="border-t border-neutral-800 py-4 shrink-0">
|
||||||
|
<p class="text-sm text-neutral-500 text-center">
|
||||||
|
No auth — anyone can post. Open source at
|
||||||
|
<a href="https://github.com/dokkae6949/homepage"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue-400 hover:text-blue-300 hover:underline transition">
|
||||||
|
dokkae6949/homepage
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,11 +1,35 @@
|
||||||
@import at.dokkae.homepage.Message
|
@import at.dokkae.homepage.templates.MessageTemplate
|
||||||
|
@import java.time.Instant
|
||||||
|
@import java.time.ZoneId
|
||||||
|
@import java.time.format.DateTimeFormatter
|
||||||
|
@import kotlin.math.absoluteValue
|
||||||
|
|
||||||
@param message: Message
|
@param model: MessageTemplate
|
||||||
|
|
||||||
<div class="message">
|
!{val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(ZoneId.systemDefault())}
|
||||||
<strong>${message.author}</strong>:
|
!{val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())}
|
||||||
${message.content}
|
!{val borderColors = listOf("red", "orange", "yellow", "green", "blue", "pink" )}
|
||||||
<span style="color:#888; font-size:.8rem;">
|
|
||||||
(${message.createdAt.toString()})
|
<div class="message-group mb-3 animate-slide-in">
|
||||||
|
<div class="flex relative px-3 py-1 hover:bg-neutral-800/30 rounded transition-colors">
|
||||||
|
<!-- Left border with rotating colors -->
|
||||||
|
<div class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3/4 rounded-r message-border-${borderColors[model.message.id.hashCode().absoluteValue % borderColors.size]}"></div>
|
||||||
|
|
||||||
|
<div class="flex-1 pl-3 text-ellipsis text-wrap break-all">
|
||||||
|
<!-- Header with username and timestamp -->
|
||||||
|
<div class="flex flex-wrap items-baseline gap-2 mb-1">
|
||||||
|
<span class="font-semibold text-white">
|
||||||
|
${model.message.author}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-neutral-400">
|
||||||
|
${dateFormatter.format(model.message.createdAt)} • ${timeFormatter.format(model.message.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Message content -->
|
||||||
|
<div class="text-neutral-200">
|
||||||
|
${model.message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
0
src/main/resources/static/css/.keep
Normal file
0
src/main/resources/static/css/.keep
Normal file
0
src/main/resources/static/css/index.css
Normal file
0
src/main/resources/static/css/index.css
Normal file
0
src/main/resources/static/images/.keep
Normal file
0
src/main/resources/static/images/.keep
Normal file
0
src/main/resources/static/js/.keep
Normal file
0
src/main/resources/static/js/.keep
Normal file
Loading…
Add table
Add a link
Reference in a new issue