This commit is contained in:
Finn Linck Ryan 2025-12-12 23:07:05 +01:00
commit 217642cd6b
18 changed files with 877 additions and 0 deletions

View file

@ -0,0 +1,88 @@
package at.dokkae.homepage
import at.dokkae.homepage.extensions.Precompiled
import at.dokkae.homepage.templates.Index
import org.http4k.core.HttpHandler
import org.http4k.core.Method.*
import org.http4k.core.Response
import org.http4k.core.Status
import org.http4k.core.body.form
import org.http4k.core.getFirst
import org.http4k.core.toParametersMap
import org.http4k.routing.bindHttp
import org.http4k.routing.bindSse
import org.http4k.routing.poly
import org.http4k.routing.routes
import org.http4k.routing.sse
import org.http4k.server.Jetty
import org.http4k.server.asServer
import org.http4k.sse.Sse
import org.http4k.sse.SseMessage
import org.http4k.sse.SseResponse
import org.http4k.template.JTETemplates
import org.http4k.template.ViewModel
import org.jetbrains.kotlin.backend.common.push
import java.time.Instant
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.concurrent.thread
data class Message (
val author: String,
val content: String,
val createdAt: Instant = Instant.now(),
val id: UUID = UUID.randomUUID(),
) : ViewModel {
override fun template(): String = "partials/Message"
}
fun main() {
val messages = CopyOnWriteArrayList<Message>()
val subscribers = CopyOnWriteArrayList<Sse>()
val renderer = JTETemplates().Precompiled("build/classes/jte")
messages.push(Message("Kurisu", "Hello World!"))
messages.push(Message("Violet", "Haii Kurisu!"))
val indexHandler: HttpHandler = {
Response(Status.OK).body(renderer(Index(messages)))
}
val sse = sse(
"/message-events" bindSse { req ->
SseResponse { sse ->
subscribers.add(sse)
sse.onClose { subscribers.remove(sse) }
}
}
)
val http = routes(
"/" bindHttp GET to indexHandler,
"/messages" bindHttp POST to { req ->
val params = req.form().toParametersMap()
val author = params.getFirst("author").takeIf { !it.isNullOrBlank() } ?: "Anonymous"
val message = params.getFirst("message")
if (message == null) {
Response(Status.BAD_REQUEST)
} else {
val msg = Message(author, message)
val sseMsg = SseMessage.Data(renderer(msg))
messages.push(msg)
subscribers.forEach {
thread { it.send(sseMsg) }
}
Response(Status.OK)
}
}
)
poly(http, sse).asServer(Jetty(port = 9000)).start()
println("Server started on http://localhost:9000")
}

View file

@ -0,0 +1,20 @@
package at.dokkae.homepage.extensions
import gg.jte.ContentType
import gg.jte.TemplateEngine
import gg.jte.output.StringOutput
import org.http4k.template.JTETemplates
import org.http4k.template.ViewModel
import org.http4k.template.ViewNotFound
import java.io.File
fun JTETemplates.Precompiled(classTemplateDir: String) =
fun(viewModel: ViewModel): String {
val templateName = viewModel.template() + ".kte"
val templateEngine = TemplateEngine.createPrecompiled(File(classTemplateDir).toPath(), ContentType.Html)
return if (templateEngine.hasTemplate(templateName))
StringOutput().also { templateEngine.render(templateName, viewModel, it); }.toString()
else throw ViewNotFound(viewModel)
}

View file

@ -0,0 +1,8 @@
package at.dokkae.homepage.templates
import at.dokkae.homepage.Message
import org.http4k.template.ViewModel
data class Index(val messages: List<Message> = listOf()) : ViewModel {
override fun template(): String = "Index"
}

0
src/main/kte/.jteroot Normal file
View file

160
src/main/kte/Index.kte Normal file
View file

@ -0,0 +1,160 @@
@import at.dokkae.homepage.templates.Index
@param model: Index
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Simple Chat — http4k + JTE + htmx</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-ext-sse@2.2.4" integrity="sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N" crossorigin="anonymous"></script>
<style>
:root {
--bg: #f5f5f7;
--card: #ffffff;
--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 {
margin: 0;
font-family: var(--font);
background: var(--bg);
display: flex;
justify-content: center;
padding: 32px 12px;
}
#chat {
width: 100%;
max-width: 560px;
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 {
margin: 0 0 16px;
font-size: 1.4rem;
color: var(--text-dark);
text-align: center;
}
#messages {
display: flex;
flex-direction: column-reverse;
gap: var(--spacing);
margin-bottom: 20px;
max-height: 60vh;
overflow-y: auto;
padding-right: 4px;
}
/* Nice scrollbar */
#messages::-webkit-scrollbar { width: 6px; }
#messages::-webkit-scrollbar-thumb {
background: #bbb;
border-radius: 3px;
}
.message {
padding: 10px 14px;
border-radius: var(--radius);
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 {
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-light);
}
.message-self {
align-self: flex-end;
background: var(--bubble-self);
}
.message-other {
align-self: flex-start;
background: var(--bubble-other);
}
form {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
input[type="text"] {
flex: 1;
padding: 10px;
font-size: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
button {
padding: 10px 16px;
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>
</head>
<body hx-ext="sse">
<main id="chat">
<h1>Simple Chat</h1>
<div id="messages" sse-connect="/message-events" sse-swap="message" hx-swap="afterbegin">
@for (message in model.messages.reversed())
@template.partials.Message(message)
@endfor
</div>
<form hx-post="/messages" hx-swap="none">
<input id="username-input" type="text" name="author" placeholder="name (optional)">
<input id="message-input" type="text" name="message" placeholder="your message" required>
<button type="submit"
hx-on::after-request="if(event.detail.successful)document.getElementById('message-input').value = ''">
Send
</button>
</form>
<p style="font-size: .9rem; color: #666">No auth — anyone can post. Messages are stored only in memory.</p>
</main>
</body>
</html>

View file

@ -0,0 +1,11 @@
@import at.dokkae.homepage.Message
@param message: Message
<div class="message">
<strong>${message.author}</strong>:
${message.content}
<span style="color:#888; font-size:.8rem;">
(${message.createdAt.toString()})
</span>
</div>

View file

@ -0,0 +1,19 @@
package at.dokkae.homepage
import org.http4k.client.OkHttp
import org.http4k.core.HttpHandler
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.then
import org.http4k.filter.DebuggingFilters.PrintResponse
fun main() {
val client: HttpHandler = OkHttp()
val printingClient: HttpHandler = PrintResponse().then(client)
val response: Response = printingClient(Request(GET, "http://localhost:9000/ping"))
println(response.bodyString())
}

View file

@ -0,0 +1,18 @@
package at.dokkae.homepage
import org.http4k.core.Method.GET
import org.http4k.core.Request
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class HomepageTest {
@Test
fun `Ping test`() {
assertEquals(
Response(OK).body("pong"),
app(Request(GET, "/ping"))
)
}
}