tmp: temporary stash
This commit is contained in:
parent
2b2e610851
commit
11aca13f38
23 changed files with 483 additions and 96 deletions
73
README.md
73
README.md
|
|
@ -1,7 +1,72 @@
|
|||
# Homepage
|
||||
|
||||
## Package
|
||||
```
|
||||
./gradlew build
|
||||
```
|
||||
## Table of Contents
|
||||
|
||||
- [Development](#Development)
|
||||
- [Building](#Building)
|
||||
- [Deployment](#Deployment)
|
||||
- [Building](#Building-1)
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
## Deployment
|
||||
|
||||
### Building
|
||||
|
||||
## Project Structure
|
||||
|
||||
```bash
|
||||
src
|
||||
├── main
|
||||
│ ├── kotlin
|
||||
│ │ └── at
|
||||
│ │ └── dokkae
|
||||
│ │ └── homepage
|
||||
│ │ ├── config
|
||||
│ │ │ └── Environment.kt
|
||||
│ │ ├── Homepage.kt # Application entrypoint
|
||||
│ │ ├── repository # Persistence layer
|
||||
│ │ │ ├── impls
|
||||
│ │ │ │ └── JooqMessageRepository.kt
|
||||
│ │ │ └── MessageRepository.kt
|
||||
│ │ └── templates
|
||||
│ │ ├── layout # HTML layouts. Usually include all page dependencies in the <head>.
|
||||
│ │ │ └── MainLayout.kt
|
||||
│ │ └── page # Usually wrapped in a layout, containing all elements found inside the <body>.
|
||||
│ │ │ └── ChatPage.kt
|
||||
│ │ └── partials # Contains all HTMX related snippets.
|
||||
│ └── resources
|
||||
│ ├── db
|
||||
│ │ └── migration
|
||||
│ │ ├── V001__add_update_and_create_timestamp_triggers.sql
|
||||
│ │ ├── V002__add_message_table.sql
|
||||
│ │ └── V003__fix_updated_at_insert_trigger.sql
|
||||
│ └── public
|
||||
│ │ # First-party static web files
|
||||
│ ├── static
|
||||
│ │ ├── css
|
||||
│ │ │ └── index.css
|
||||
│ │ ├── images
|
||||
│ │ └── js
|
||||
│ │ # External web dependencies. Uses the format '<name>/<version or first 8 characters from sha256>/<file>'.
|
||||
│ └── vendor
|
||||
│ ├── htmx
|
||||
│ │ └── 2.0.8
|
||||
│ │ └── htmx.min.js
|
||||
│ ├── htmx-ext-sse
|
||||
│ │ └── 2.2.4
|
||||
│ │ └── htmx-ext-sse.min.js
|
||||
│ ├── hyperscript
|
||||
│ │ └── 3e834a3f
|
||||
│ │ └── hyperscript.min.js
|
||||
│ └── tailwindcss
|
||||
│ └── 095aecf0
|
||||
│ └── tailwindcss.min.js
|
||||
└── test
|
||||
└── kotlin
|
||||
└── at
|
||||
└── dokkae
|
||||
└── homepage # Tests (and benchmarks?)
|
||||
```
|
||||
|
|
@ -77,12 +77,13 @@ dependencies {
|
|||
// HTTP4K
|
||||
implementation(platform(libs.http4k.bom))
|
||||
implementation(libs.bundles.http4k)
|
||||
implementation("org.http4k.pro:http4k-tools-hotreload")
|
||||
|
||||
// Environment & Configuration
|
||||
implementation(libs.dotenv)
|
||||
|
||||
// Templating
|
||||
implementation(libs.htmlflow.kotlin)
|
||||
implementation(libs.bundles.templating)
|
||||
|
||||
// Database
|
||||
implementation(libs.bundles.database)
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=false
|
||||
org.gradle.configuration-cache=true
|
||||
kotlin.incremental=true
|
||||
kotlin.daemon.jvmargs=-Xmx4g
|
||||
|
|
@ -4,7 +4,7 @@ shadow = "9.3.0"
|
|||
dotenv-plugin = "1.1.3"
|
||||
dotenv = "6.5.1"
|
||||
http4k = "6.23.1.0"
|
||||
htmlflow = "5.0.1"
|
||||
kotlinx-html = "0.12.0"
|
||||
flyway = "11.19.0"
|
||||
jooq = "3.20.10"
|
||||
junit = "6.0.0"
|
||||
|
|
@ -27,6 +27,11 @@ http4k = [
|
|||
"http4k-web-htmx"
|
||||
]
|
||||
|
||||
templating = [
|
||||
"kotlinx-html",
|
||||
"kotlinx-html-jvm"
|
||||
]
|
||||
|
||||
testing = [
|
||||
"http4k-testing-approval",
|
||||
"http4k-testing-hamkrest",
|
||||
|
|
@ -55,7 +60,8 @@ http4k-server-jetty = { module = "org.http4k:http4k-server-jetty" }
|
|||
http4k-web-htmx = { module = "org.http4k:http4k-web-htmx" }
|
||||
|
||||
# HTML Templating
|
||||
htmlflow-kotlin = { module = "com.github.xmlet:htmlflow-kotlin", version.ref = "htmlflow" }
|
||||
kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html", version.ref = "kotlinx-html" }
|
||||
kotlinx-html-jvm = { module = "org.jetbrains.kotlinx:kotlinx-html-jvm", version.ref = "kotlinx-html" }
|
||||
|
||||
# Database Driver
|
||||
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
|
||||
|
|
|
|||
|
|
@ -1,34 +1,48 @@
|
|||
package at.dokkae.homepage
|
||||
|
||||
import at.dokkae.homepage.config.Env
|
||||
import at.dokkae.homepage.config.Environment
|
||||
import at.dokkae.homepage.extensions.Precompiled
|
||||
import at.dokkae.homepage.repository.MessageRepository
|
||||
import at.dokkae.homepage.repository.impls.JooqMessageRepository
|
||||
import at.dokkae.homepage.templates.IndexTemplate
|
||||
import at.dokkae.homepage.templates.MessageTemplate
|
||||
import at.dokkae.homepage.templates.page.chatPage
|
||||
import at.dokkae.homepage.templates.partials.messagePartial
|
||||
import io.github.cdimascio.dotenv.dotenv
|
||||
import kotlinx.html.div
|
||||
import kotlinx.html.dom.createHTMLDocument
|
||||
import kotlinx.html.dom.serialize
|
||||
import kotlinx.html.html
|
||||
import kotlinx.html.stream.createHTML
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.http4k.core.ContentType
|
||||
import org.http4k.core.HttpHandler
|
||||
import org.http4k.core.Method.*
|
||||
import org.http4k.core.MimeTypes
|
||||
import org.http4k.core.PolyHandler
|
||||
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.core.with
|
||||
import org.http4k.hotreload.HotReloadServer
|
||||
import org.http4k.hotreload.HotReloadable
|
||||
import org.http4k.lens.Header
|
||||
import org.http4k.lens.html
|
||||
import org.http4k.routing.ResourceLoader
|
||||
import org.http4k.routing.RoutingHandler
|
||||
import org.http4k.routing.bindHttp
|
||||
import org.http4k.routing.bindSse
|
||||
import org.http4k.routing.path
|
||||
import org.http4k.routing.poly
|
||||
import org.http4k.routing.routes
|
||||
import org.http4k.routing.sse
|
||||
import org.http4k.routing.static
|
||||
import org.http4k.server.Jetty
|
||||
import org.http4k.server.PolyServerConfig
|
||||
import org.http4k.server.ServerConfig
|
||||
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.jooq.SQLDialect
|
||||
import org.jooq.impl.DSL
|
||||
import java.sql.DriverManager
|
||||
|
|
@ -39,7 +53,7 @@ import kotlin.concurrent.thread
|
|||
|
||||
fun migrateDatabase(env: Environment) {
|
||||
val flyway = Flyway.configure()
|
||||
.dataSource(env.dbUrl, env.dbUsername, env.dbPassword)
|
||||
.dataSource(env.db.url, env.db.username, env.db.password)
|
||||
.locations("classpath:db/migration")
|
||||
.baselineOnMigrate(true) // optional: creates baseline if no schema history exists
|
||||
.load()
|
||||
|
|
@ -48,48 +62,49 @@ fun migrateDatabase(env: Environment) {
|
|||
println("Migrated ${result.migrationsExecuted} migration${if (result.migrationsExecuted != 1) "s" else ""}")
|
||||
}
|
||||
|
||||
data class Message(
|
||||
val author: String,
|
||||
val content: String,
|
||||
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val createdAt: Instant = Instant.now(),
|
||||
val updatedAt: Instant? = null
|
||||
) {
|
||||
init {
|
||||
require(author.length <= 31) { "Author must be 31 characters or less" }
|
||||
require(content.length <= 255) { "Content must be 255 characters or less" }
|
||||
}
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val env = Environment.load(dotenv {
|
||||
ignoreIfMissing = true
|
||||
ignoreIfMalformed = true
|
||||
})
|
||||
|
||||
if (env.dbMigrate) {
|
||||
if (env.db.migrate) {
|
||||
migrateDatabase(env)
|
||||
}
|
||||
|
||||
val connection = DriverManager.getConnection(env.dbUrl, env.dbUsername, env.dbPassword)
|
||||
val connection = DriverManager.getConnection(env.db.url, env.db.username, env.db.password)
|
||||
val dslContext = DSL.using(connection, SQLDialect.POSTGRES)
|
||||
val messageRepository: MessageRepository = JooqMessageRepository(dslContext)
|
||||
|
||||
val subscribers = CopyOnWriteArrayList<Sse>()
|
||||
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 = {
|
||||
Response(Status.OK).body(renderer(IndexTemplate(messageRepository.findAll())))
|
||||
val html = createHTMLDocument().html {
|
||||
chatPage(
|
||||
messages = messageRepository.findAll()
|
||||
)
|
||||
}.serialize()
|
||||
|
||||
Response(Status.OK).html(html)
|
||||
}
|
||||
|
||||
val vendorHandler: HttpHandler = handler@{ req ->
|
||||
val name = req.path("name") ?: return@handler Response(Status.NOT_FOUND)
|
||||
val version = req.path("version") ?: return@handler Response(Status.NOT_FOUND)
|
||||
val file = req.path("file") ?: return@handler Response(Status.NOT_FOUND)
|
||||
val contentType = MimeTypes().forFile(file)
|
||||
|
||||
if (contentType == ContentType.OCTET_STREAM) {
|
||||
return@handler Response(Status.NOT_FOUND)
|
||||
}
|
||||
|
||||
val resource = ResourceLoader.Classpath("/public/vendor/$name/$version").load(file)
|
||||
?: return@handler Response(Status.NOT_FOUND)
|
||||
|
||||
Response(Status.OK)
|
||||
.with(Header.CONTENT_TYPE of contentType)
|
||||
.header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
.body(resource.openStream())
|
||||
}
|
||||
|
||||
val sse = sse(
|
||||
|
|
@ -97,13 +112,16 @@ fun main() {
|
|||
SseResponse { sse ->
|
||||
subscribers.add(sse)
|
||||
|
||||
sse.send(SseMessage.Event("connected", "Connection established"))
|
||||
sse.onClose { subscribers.remove(sse) }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val http = routes(
|
||||
static(ResourceLoader.Classpath("static")),
|
||||
static(ResourceLoader.Classpath("public/static")),
|
||||
|
||||
"/public/vendor/{name}/{version}/{file}" bindHttp GET to vendorHandler,
|
||||
|
||||
"/" bindHttp GET to indexHandler,
|
||||
|
||||
|
|
@ -117,7 +135,9 @@ fun main() {
|
|||
Response(Status.BAD_REQUEST)
|
||||
} else {
|
||||
val msg = Message(author, message)
|
||||
val sseMsg = SseMessage.Data(renderer(MessageTemplate(msg)))
|
||||
val sseMsg = SseMessage.Data(createHTML().div {
|
||||
messagePartial(msg)
|
||||
})
|
||||
|
||||
messageRepository.save(msg)
|
||||
subscribers.forEach {
|
||||
|
|
@ -134,7 +154,7 @@ fun main() {
|
|||
}
|
||||
)
|
||||
|
||||
poly(http, sse).asServer(Jetty(port = env.appPort)).start()
|
||||
poly(http, sse).asServer(Jetty(9000)).start()
|
||||
|
||||
println("Server started on http://${env.appDomain}:${env.appPort}")
|
||||
println("Server started on http://${env.app.domain}:${env.app.port}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,27 +8,40 @@ enum class Env {
|
|||
}
|
||||
|
||||
data class Environment(
|
||||
val appPort: Int,
|
||||
val appDomain: String,
|
||||
val appEnv: Env,
|
||||
val dbUrl: String,
|
||||
val dbUsername: String,
|
||||
val dbPassword: String,
|
||||
val dbMigrate: Boolean,
|
||||
val app: AppEnvironment,
|
||||
val db: DbEnvironment,
|
||||
) {
|
||||
data class AppEnvironment(
|
||||
val port: Int,
|
||||
val domain: String,
|
||||
val env: Env,
|
||||
)
|
||||
|
||||
data class DbEnvironment(
|
||||
val url: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val migrate: Boolean,
|
||||
)
|
||||
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Returns a loaded Environment object instance.
|
||||
* @throws IllegalStateException if required environment variables were not found within the provided `dotenv` instance.
|
||||
*/
|
||||
fun load(dotenv: Dotenv): Environment = Environment(
|
||||
appPort = requireEnv(dotenv, "APP_PORT").toInt(),
|
||||
appDomain = requireEnv(dotenv, "APP_DOMAIN"),
|
||||
appEnv = Env.valueOf(requireEnv(dotenv, "APP_ENV").uppercase()),
|
||||
dbUrl = requireEnv(dotenv, "DB_URL"),
|
||||
dbUsername = requireEnv(dotenv, "DB_USERNAME"),
|
||||
dbPassword = requireEnv(dotenv, "DB_PASSWORD"),
|
||||
dbMigrate = dotenv["DB_MIGRATE"]?.toBoolean() ?: false
|
||||
app = AppEnvironment(
|
||||
port = requireEnv(dotenv, "APP_PORT").toInt(),
|
||||
domain = requireEnv(dotenv, "APP_DOMAIN"),
|
||||
env = Env.valueOf(requireEnv(dotenv, "APP_ENV").uppercase()),
|
||||
),
|
||||
db = DbEnvironment(
|
||||
url = requireEnv(dotenv, "DB_URL"),
|
||||
username = requireEnv(dotenv, "DB_USERNAME"),
|
||||
password = requireEnv(dotenv, "DB_PASSWORD"),
|
||||
migrate = dotenv["DB_MIGRATE"]?.toBoolean() ?: false
|
||||
),
|
||||
)
|
||||
|
||||
private fun requireEnv(dotenv: Dotenv, key: String): String {
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
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)
|
||||
}
|
||||
18
src/main/kotlin/at/dokkae/homepage/model/Message.kt
Normal file
18
src/main/kotlin/at/dokkae/homepage/model/Message.kt
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package at.dokkae.homepage.model
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
data class Message(
|
||||
val author: String,
|
||||
val content: String,
|
||||
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val createdAt: Instant = Instant.now(),
|
||||
val updatedAt: Instant? = null
|
||||
) {
|
||||
init {
|
||||
require(author.length <= 31) { "Author must be 31 characters or less" }
|
||||
require(content.length <= 255) { "Content must be 255 characters or less" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package at.dokkae.homepage.templates
|
||||
|
||||
import at.dokkae.homepage.Message
|
||||
import org.http4k.template.ViewModel
|
||||
|
||||
data class IndexTemplate(val messages: List<Message> = listOf()) : ViewModel {
|
||||
override fun template(): String = "Index"
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
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"
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package at.dokkae.homepage.templates.layout
|
||||
|
||||
import kotlinx.html.BODY
|
||||
import kotlinx.html.HTML
|
||||
import kotlinx.html.*
|
||||
|
||||
fun HTML.mainLayout(
|
||||
title: String,
|
||||
content: BODY.() -> Unit = {}
|
||||
) {
|
||||
head {
|
||||
meta { charset = "utf-8" }
|
||||
meta {
|
||||
name = "viewport"
|
||||
this.content = "width=device-width, initial-scale=1"
|
||||
}
|
||||
|
||||
this.title(title)
|
||||
|
||||
script { src = "/public/vendor/htmx/2.0.8/htmx.min.js" }
|
||||
script { src = "/public/vendor/htmx-ext-sse/2.2.4/htmx-ext-sse.min.js" }
|
||||
script { src = "/public/vendor/hyperscript/3e834a3f/hyperscript.min.js" }
|
||||
script { src = "/public/vendor/tailwindcss/095aecf0/tailwindcss.min.js" }
|
||||
}
|
||||
body(classes = "h-dvh") {
|
||||
attributes["hx-ext"] = "sse"
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
159
src/main/kotlin/at/dokkae/homepage/templates/page/ChatPage.kt
Normal file
159
src/main/kotlin/at/dokkae/homepage/templates/page/ChatPage.kt
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package at.dokkae.homepage.templates.page
|
||||
|
||||
import at.dokkae.homepage.Message
|
||||
import at.dokkae.homepage.templates.layout.mainLayout
|
||||
import at.dokkae.homepage.templates.partials.messagePartial
|
||||
import kotlinx.html.*
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
fun HTML.chatPage(
|
||||
username: String = "Anonymous",
|
||||
messages: List<Message> = listOf(),
|
||||
) {
|
||||
mainLayout("Dokkae's Chat") {
|
||||
unsafe {
|
||||
+"""
|
||||
<svg id="svgfilters" width="0" height="0" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||
<defs>
|
||||
<filter id="wiggle">
|
||||
<feturbulence basefrequency="0.02" id="turbulence-3" numoctaves="3" result="noise" seed="3"></feturbulence>
|
||||
<fedisplacementmap in2="noise" in="SourceGraphic" scale="10"></fedisplacementmap>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
style {
|
||||
unsafe {
|
||||
+"""
|
||||
.squiggly {
|
||||
filter: url(#wiggle);
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "w-full h-full flex flex-row") {
|
||||
classes += "text-neutral-100 bg-neutral-800 p-0 lg:p-4 gap-4"
|
||||
|
||||
div(classes = "flex-1 flex flex-col min-w-0") {
|
||||
classes += "gap-0 lg:gap-4"
|
||||
|
||||
header(classes = "flex justify-between items-center") {
|
||||
classes += "bg-neutral-900 p-4 rounded-none lg:rounded-2xl border-b border-neutral-800 lg:border-none"
|
||||
|
||||
div {
|
||||
p(classes = "font-semibold") { +"Dokkae's Chat" }
|
||||
small(classes = "text-gray-400") {
|
||||
//+"Connected • User-Name"
|
||||
+"Post whatever, whenever from wherever"
|
||||
}
|
||||
}
|
||||
|
||||
button(classes = "lg:hidden") {
|
||||
classes += "px-3 py-2 rounded-lg bg-neutral-700 hover:scale-110"
|
||||
classes += "transition-transform duration-150"
|
||||
attributes["_"] = "on click remove .hidden from #sidebar-overlay then wait 5ms then remove .opacity-0 from #sidebar-overlay then remove .translate-x-full from #sidebar"
|
||||
|
||||
+"☰"
|
||||
}
|
||||
}
|
||||
|
||||
main(classes = "flex-1 overflow-hidden flex flex-col") {
|
||||
classes += "bg-neutral-900 p-4 rounded-none lg:rounded-2xl gap-4"
|
||||
|
||||
div(classes = "flex-1 flex flex-col-reverse overflow-x-hidden overflow-y-auto") {
|
||||
classes += "gap-4"
|
||||
attributes["sse-connect"] = "/message-events"
|
||||
attributes["sse-swap"] = "message"
|
||||
attributes["hx-swap"] = "afterbegin"
|
||||
|
||||
messages.map { messagePartial(it) }
|
||||
}
|
||||
|
||||
form(classes = "flex flex-row") {
|
||||
classes += "bg-neutral-800 pl-4 pr-2 py-2 gap-4 rounded-xl"
|
||||
attributes["hx-post"] = "/messages"
|
||||
attributes["hx-swap"] = "none"
|
||||
attributes["_"] = """
|
||||
on htmx:afterRequest(detail)
|
||||
if detail.successful
|
||||
set #message-input.value to ''
|
||||
end
|
||||
""".trimIndent()
|
||||
|
||||
input(classes = "flex-1") {
|
||||
id = "message-input"
|
||||
type = InputType.text
|
||||
name = "message"
|
||||
placeholder = "Your message..."
|
||||
required = true
|
||||
autoComplete = "off"
|
||||
}
|
||||
|
||||
button {
|
||||
type = ButtonType.submit
|
||||
classes += "px-3 py-2 rounded-lg bg-pink-300 hover:bg-pink-200 hover:scale-110"
|
||||
classes += "transition-transform duration-150"
|
||||
|
||||
unsafe {
|
||||
+"""
|
||||
<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>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "fixed inset-0 bg-black/50 lg:hidden hidden opacity-0 z-10") {
|
||||
id = "sidebar-overlay"
|
||||
classes += "transition-opacity duration-300"
|
||||
attributes["_"] = "on click add .translate-x-full to #sidebar then add .opacity-0 to me then wait 300ms then add .hidden to me"
|
||||
}
|
||||
|
||||
aside(classes = "w-2/3 lg:w-1/3 lg:max-w-128 fixed lg:static top-0 right-0 h-full translate-x-full lg:translate-x-0 z-20 lg:z-auto") {
|
||||
id = "sidebar"
|
||||
classes += "flex flex-col space-between gap-4"
|
||||
classes += "transition-all duration-300 ease-in-out"
|
||||
classes += "bg-neutral-900 rounded-2xl p-4"
|
||||
|
||||
div(classes = "flex justify-between items-center") {
|
||||
button(classes = "lg:hidden") {
|
||||
classes += "px-3 py-2 rounded-lg bg-neutral-700 hover:scale-110"
|
||||
classes += "transition-transform duration-150"
|
||||
attributes["_"] = "on click add .translate-x-full to #sidebar then add .opacity-0 to #sidebar-overlay then wait 300ms then add .hidden to #sidebar-overlay"
|
||||
|
||||
+"✕"
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "flex-1") {
|
||||
+"Sidebar Content"
|
||||
}
|
||||
|
||||
footer(classes = "flex items-center justify-center") {
|
||||
small(classes = "w-full text-center") {
|
||||
classes += "text-neutral-400"
|
||||
|
||||
+"No auth — anyone can post. Open source at "
|
||||
a {
|
||||
classes += "text-pink-300 hover:text-pink-200 hover:underline transition"
|
||||
|
||||
href = "https://github.com/dokkae6949/homepage"
|
||||
target = "_blank"
|
||||
rel = "noopener noreferrer"
|
||||
|
||||
+"dokkae6949/homepage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package at.dokkae.homepage.templates.partials
|
||||
|
||||
import at.dokkae.homepage.Message
|
||||
import kotlinx.html.FlowContent
|
||||
import kotlinx.html.*
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
fun FlowContent.messagePartial(message: Message) {
|
||||
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(ZoneOffset.UTC)
|
||||
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneOffset.UTC)
|
||||
val colors = listOf(
|
||||
"red",
|
||||
"orange",
|
||||
"amber",
|
||||
"yellow",
|
||||
"lime",
|
||||
"green",
|
||||
"emerald",
|
||||
"teal",
|
||||
"cyan",
|
||||
"sky",
|
||||
"blue",
|
||||
"indigo",
|
||||
"violet",
|
||||
"purple",
|
||||
"fuchsia",
|
||||
"pink",
|
||||
"rose"
|
||||
)
|
||||
val bgColorClass = "bg-${colors[message.id.hashCode().absoluteValue % colors.size]}-200"
|
||||
|
||||
div {
|
||||
div(classes = "flex relative pl-2 py-1 hover:bg-neutral-800/30 rounded-xl transition-colors") {
|
||||
div(classes = "absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3/4 rounded-r $bgColorClass")
|
||||
|
||||
div(classes = "flex-1 pl-3 text-ellipsis text-wrap break-all") {
|
||||
div(classes = "flex flex-wrap items-baseline gap-2 mb-1") {
|
||||
span(classes = "font-semibold text-white") {
|
||||
+message.author
|
||||
}
|
||||
span(classes = "text-xs text-neutral-400") {
|
||||
+dateFormatter.format(message.createdAt)
|
||||
+" • "
|
||||
+timeFormatter.format(message.createdAt)
|
||||
+" UTC"
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "text-neutral-200") {
|
||||
+message.content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package at.dokkae.homepage.web.controller
|
||||
|
||||
import org.http4k.core.Body
|
||||
import org.http4k.core.Method.*
|
||||
import org.http4k.core.PolyHandler
|
||||
import org.http4k.core.Request
|
||||
import org.http4k.core.Response
|
||||
import org.http4k.core.Status
|
||||
import org.http4k.lens.FormField
|
||||
import org.http4k.lens.LensFailure
|
||||
import org.http4k.lens.Validator
|
||||
import org.http4k.lens.nonEmptyString
|
||||
import org.http4k.lens.webForm
|
||||
import org.http4k.routing.bind
|
||||
import org.http4k.routing.poly
|
||||
import org.http4k.routing.routes
|
||||
|
||||
class MessageController(
|
||||
|
||||
) {
|
||||
private val messageField = FormField.nonEmptyString().required("message")
|
||||
private val messageCreationForm = Body.webForm(Validator.Strict, messageField).toLens()
|
||||
|
||||
val routes: PolyHandler = poly(
|
||||
routes(
|
||||
"/messages" bind POST to ::createMessage
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
private fun createMessage(req: Request): Response {
|
||||
return try {
|
||||
val fields = messageCreationForm(req).fields
|
||||
val content = fields["message"]
|
||||
|
||||
Response(Status.CREATED)
|
||||
} catch (e: LensFailure) {
|
||||
Response(Status.BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/main/resources/public/vendor/htmx-ext-sse/2.2.4/htmx-ext-sse.min.js
vendored
Normal file
1
src/main/resources/public/vendor/htmx-ext-sse/2.2.4/htmx-ext-sse.min.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e,{contextElement:t})}function v(e){return g.getInternalData(e).sseEventSource!=null}})();
|
||||
1
src/main/resources/public/vendor/htmx/2.0.8/htmx.min.js
vendored
Normal file
1
src/main/resources/public/vendor/htmx/2.0.8/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/public/vendor/hyperscript/3e834a3f/hyperscript.min.js
vendored
Normal file
1
src/main/resources/public/vendor/hyperscript/3e834a3f/hyperscript.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
src/main/resources/public/vendor/tailwindcss/095aecf0/tailwindcss.min.js
vendored
Normal file
8
src/main/resources/public/vendor/tailwindcss/095aecf0/tailwindcss.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue