Compare commits

..

2 commits

Author SHA1 Message Date
11aca13f38 tmp: temporary stash 2026-01-11 01:29:00 +01:00
2b2e610851 swap jte for htmlflow dependencies 2025-12-15 16:24:21 +01:00
42 changed files with 486 additions and 752 deletions

1
.gitignore vendored
View file

@ -58,7 +58,6 @@ gradle-app.setting
# Allow generated code fragments for Docker builds # Allow generated code fragments for Docker builds
!build/generated-src/** !build/generated-src/**
!build/generated-resources/**
################### ###################
### Environment ### ### Environment ###

View file

@ -13,10 +13,9 @@ COPY --chown=gradle:gradle src ./src
# Copy pre-generated code fragments # Copy pre-generated code fragments
COPY --chown=gradle:gradle build/generated-src ./build/generated-src COPY --chown=gradle:gradle build/generated-src ./build/generated-src
COPY --chown=gradle:gradle build/generated-resources ./build/generated-resources
# Build the fat jar without cleaning (preserves generated code) # Build the fat jar without cleaning (preserves generated code)
RUN ./gradlew build -x clean -x cleanGenerated -x jooqCodegen -x flywayMigrate -x precompileJte --no-daemon RUN ./gradlew build -x clean -x cleanGenerated -x jooqCodegen -x flywayMigrate --no-daemon
# --- Stage 2: Run the app --- # --- Stage 2: Run the app ---
FROM eclipse-temurin:21-jdk-alpine FROM eclipse-temurin:21-jdk-alpine

View file

@ -1,7 +1,72 @@
# Homepage # Homepage
## Package ## Table of Contents
```
./gradlew build
```
- [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?)
```

View file

@ -1,10 +1,8 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import io.github.klahap.dotenv.DotEnv
import io.github.klahap.dotenv.DotEnvBuilder import io.github.klahap.dotenv.DotEnvBuilder
import org. gradle.api.JavaVersion.VERSION_21 import org. gradle.api.JavaVersion.VERSION_21
import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 import org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import kotlin.io.path.Path
// ==================================================================================================== // ====================================================================================================
// ENVIRONMENT CONFIGURATION // ENVIRONMENT CONFIGURATION
@ -33,7 +31,6 @@ plugins {
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow) alias(libs.plugins.shadow)
alias(libs.plugins.dotenv.plugin) alias(libs.plugins.dotenv.plugin)
alias(libs.plugins.jte)
alias(libs.plugins.tasktree) alias(libs.plugins.tasktree)
alias(libs.plugins.jooq.codegen.gradle) alias(libs.plugins.jooq.codegen.gradle)
alias(libs.plugins.flyway) alias(libs.plugins.flyway)
@ -62,16 +59,12 @@ repositories {
// GENERATED CODE DIRECTORIES // GENERATED CODE DIRECTORIES
// ==================================================================================================== // ====================================================================================================
val generatedResourcesDir = layout.buildDirectory.dir("generated-resources")
val generatedSourcesDir = layout.buildDirectory.dir("generated-src") val generatedSourcesDir = layout.buildDirectory.dir("generated-src")
val migrationSourceDir = layout.projectDirectory.dir("src/main/resources/db/migration") val migrationSourceDir = layout.projectDirectory.dir("src/main/resources/db/migration")
val jtwSourceDir = layout.projectDirectory.dir("src/main/kte")
val jteOutputDir = generatedResourcesDir.get().dir("jte")
val jooqOutputDir = generatedSourcesDir.get().dir("jooq") val jooqOutputDir = generatedSourcesDir.get().dir("jooq")
sourceSets { sourceSets {
main { main {
resources.srcDir(jteOutputDir)
kotlin.srcDir(jooqOutputDir) kotlin.srcDir(jooqOutputDir)
} }
} }
@ -84,12 +77,13 @@ dependencies {
// HTTP4K // HTTP4K
implementation(platform(libs.http4k.bom)) implementation(platform(libs.http4k.bom))
implementation(libs.bundles.http4k) implementation(libs.bundles.http4k)
implementation("org.http4k.pro:http4k-tools-hotreload")
// Environment & Configuration // Environment & Configuration
implementation(libs.dotenv) implementation(libs.dotenv)
// Templating // Templating
implementation(libs.jte.kotlin) implementation(libs.bundles.templating)
// Database // Database
implementation(libs.bundles.database) implementation(libs.bundles.database)
@ -117,27 +111,6 @@ buildscript {
} }
} }
// ====================================================================================================
// JTE TEMPLATE GENERATION
// ====================================================================================================
jte {
sourceDirectory.set(Path(jtwSourceDir.asFile.absolutePath))
targetDirectory.set(Path(jteOutputDir.asFile.absolutePath))
precompile()
}
tasks.named("precompileJte") {
dependsOn("compileKotlin")
}
tasks.register("genJte") {
group = "codegen"
description = "Precompile jte template into classes"
dependsOn("precompileJte")
}
// ==================================================================================================== // ====================================================================================================
// JOOQ CODE GENERATION FROM SQL FILES // JOOQ CODE GENERATION FROM SQL FILES
// ==================================================================================================== // ====================================================================================================
@ -263,9 +236,7 @@ tasks.named<ShadowJar>("shadowJar") {
attributes("Main-Class" to "at.dokkae.homepage.HomepageKt") attributes("Main-Class" to "at.dokkae.homepage.HomepageKt")
} }
dependsOn("genJte", "genJooq") dependsOn("genJooq")
from(jteOutputDir)
archiveFileName.set("app.jar") archiveFileName.set("app.jar")
@ -296,7 +267,6 @@ tasks.register("cleanGenerated") {
description = "Clean all generated code" description = "Clean all generated code"
doLast { doLast {
delete(generatedResourcesDir)
delete(generatedSourcesDir) delete(generatedSourcesDir)
logger.lifecycle("✓ Cleaned generated code directories") logger.lifecycle("✓ Cleaned generated code directories")
} }

View file

@ -1,35 +0,0 @@
@file:Suppress("ktlint")
package gg.jte.generated.precompiled
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.precompiled.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);
}
}
}

View file

@ -1,46 +0,0 @@
@file:Suppress("ktlint")
package gg.jte.generated.precompiled.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);
}
}
}

View file

@ -1,43 +0,0 @@
/*
* This file is generated by jOOQ.
*/
package at.dokkae.homepage.generated.jooq
import kotlin.collections.List
import org.jooq.Constants
import org.jooq.Schema
import org.jooq.impl.CatalogImpl
/**
* This class is generated by jOOQ.
*/
@Suppress("warnings")
open class DefaultCatalog : CatalogImpl("") {
companion object {
/**
* The reference instance of <code>DEFAULT_CATALOG</code>
*/
val DEFAULT_CATALOG: DefaultCatalog = DefaultCatalog()
}
/**
* standard public schema
*/
val PUBLIC: Public get(): Public = Public.PUBLIC
override fun getSchemas(): List<Schema> = listOf(
Public.PUBLIC
)
/**
* A reference to the 3.20 minor release of the code generator. If this
* doesn't compile, it's because the runtime library uses an older minor
* release, namely: 3.20. You can turn off the generation of this reference
* by specifying /configuration/generator/generate/jooqVersionReference
*/
private val REQUIRE_RUNTIME_JOOQ_VERSION = Constants.VERSION_3_20
}

View file

@ -1,40 +0,0 @@
/*
* This file is generated by jOOQ.
*/
package at.dokkae.homepage.generated.jooq
import at.dokkae.homepage.generated.jooq.tables.Message
import kotlin.collections.List
import org.jooq.Catalog
import org.jooq.Table
import org.jooq.impl.DSL
import org.jooq.impl.SchemaImpl
/**
* standard public schema
*/
@Suppress("warnings")
open class Public : SchemaImpl(DSL.name("public"), DefaultCatalog.DEFAULT_CATALOG, DSL.comment("standard public schema")) {
companion object {
/**
* The reference instance of <code>public</code>
*/
val PUBLIC: Public = Public()
}
/**
* The table <code>public.message</code>.
*/
val MESSAGE: Message get() = Message.MESSAGE
override fun getCatalog(): Catalog = DefaultCatalog.DEFAULT_CATALOG
override fun getTables(): List<Table<*>> = listOf(
Message.MESSAGE
)
}

View file

@ -1,21 +0,0 @@
@file:Suppress("warnings")
/*
* This file is generated by jOOQ.
*/
package at.dokkae.homepage.generated.jooq.keys
import at.dokkae.homepage.generated.jooq.tables.Message
import at.dokkae.homepage.generated.jooq.tables.records.MessageRecord
import org.jooq.UniqueKey
import org.jooq.impl.DSL
import org.jooq.impl.Internal
// -------------------------------------------------------------------------
// UNIQUE and PRIMARY KEY definitions
// -------------------------------------------------------------------------
val MESSAGE_PKEY: UniqueKey<MessageRecord> = Internal.createUniqueKey(Message.MESSAGE, DSL.name("message_pkey"), arrayOf(Message.MESSAGE.ID), true)

View file

@ -1,187 +0,0 @@
/*
* This file is generated by jOOQ.
*/
package at.dokkae.homepage.generated.jooq.tables
import at.dokkae.homepage.generated.jooq.Public
import at.dokkae.homepage.generated.jooq.keys.MESSAGE_PKEY
import at.dokkae.homepage.generated.jooq.tables.records.MessageRecord
import java.time.OffsetDateTime
import java.util.UUID
import kotlin.collections.Collection
import org.jooq.Condition
import org.jooq.Field
import org.jooq.ForeignKey
import org.jooq.InverseForeignKey
import org.jooq.Name
import org.jooq.PlainSQL
import org.jooq.QueryPart
import org.jooq.Record
import org.jooq.SQL
import org.jooq.Schema
import org.jooq.Select
import org.jooq.Stringly
import org.jooq.Table
import org.jooq.TableField
import org.jooq.TableOptions
import org.jooq.UniqueKey
import org.jooq.impl.DSL
import org.jooq.impl.SQLDataType
import org.jooq.impl.TableImpl
/**
* This class is generated by jOOQ.
*/
@Suppress("warnings")
open class Message(
alias: Name,
path: Table<out Record>?,
childPath: ForeignKey<out Record, MessageRecord>?,
parentPath: InverseForeignKey<out Record, MessageRecord>?,
aliased: Table<MessageRecord>?,
parameters: Array<Field<*>?>?,
where: Condition?
): TableImpl<MessageRecord>(
alias,
Public.PUBLIC,
path,
childPath,
parentPath,
aliased,
parameters,
DSL.comment(""),
TableOptions.table(),
where,
) {
companion object {
/**
* The reference instance of <code>public.message</code>
*/
val MESSAGE: Message = Message()
}
/**
* The class holding records for this type
*/
override fun getRecordType(): Class<MessageRecord> = MessageRecord::class.java
/**
* The column <code>public.message.id</code>.
*/
val ID: TableField<MessageRecord, UUID?> = createField(DSL.name("id"), SQLDataType.UUID.nullable(false), this, "")
/**
* The column <code>public.message.author</code>.
*/
val AUTHOR: TableField<MessageRecord, String?> = createField(DSL.name("author"), SQLDataType.VARCHAR(31).nullable(false), this, "")
/**
* The column <code>public.message.content</code>.
*/
val CONTENT: TableField<MessageRecord, String?> = createField(DSL.name("content"), SQLDataType.VARCHAR(255).nullable(false), this, "")
/**
* The column <code>public.message.created_at</code>.
*/
val CREATED_AT: TableField<MessageRecord, OffsetDateTime?> = createField(DSL.name("created_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6).nullable(false).defaultValue(DSL.field(DSL.raw("CURRENT_TIMESTAMP"), SQLDataType.TIMESTAMPWITHTIMEZONE)), this, "")
/**
* The column <code>public.message.updated_at</code>.
*/
val UPDATED_AT: TableField<MessageRecord, OffsetDateTime?> = createField(DSL.name("updated_at"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, "")
private constructor(alias: Name, aliased: Table<MessageRecord>?): this(alias, null, null, null, aliased, null, null)
private constructor(alias: Name, aliased: Table<MessageRecord>?, parameters: Array<Field<*>?>?): this(alias, null, null, null, aliased, parameters, null)
private constructor(alias: Name, aliased: Table<MessageRecord>?, where: Condition?): this(alias, null, null, null, aliased, null, where)
/**
* Create an aliased <code>public.message</code> table reference
*/
constructor(alias: String): this(DSL.name(alias))
/**
* Create an aliased <code>public.message</code> table reference
*/
constructor(alias: Name): this(alias, null)
/**
* Create a <code>public.message</code> table reference
*/
constructor(): this(DSL.name("message"), null)
override fun getSchema(): Schema? = if (aliased()) null else Public.PUBLIC
override fun getPrimaryKey(): UniqueKey<MessageRecord> = MESSAGE_PKEY
override fun `as`(alias: String): Message = Message(DSL.name(alias), this)
override fun `as`(alias: Name): Message = Message(alias, this)
override fun `as`(alias: Table<*>): Message = Message(alias.qualifiedName, this)
/**
* Rename this table
*/
override fun rename(name: String): Message = Message(DSL.name(name), null)
/**
* Rename this table
*/
override fun rename(name: Name): Message = Message(name, null)
/**
* Rename this table
*/
override fun rename(name: Table<*>): Message = Message(name.qualifiedName, null)
/**
* Create an inline derived table from this table
*/
override fun where(condition: Condition?): Message = Message(qualifiedName, if (aliased()) this else null, condition)
/**
* Create an inline derived table from this table
*/
override fun where(conditions: Collection<Condition>): Message = where(DSL.and(conditions))
/**
* Create an inline derived table from this table
*/
override fun where(vararg conditions: Condition?): Message = where(DSL.and(*conditions))
/**
* Create an inline derived table from this table
*/
override fun where(condition: Field<Boolean?>?): Message = where(DSL.condition(condition))
/**
* Create an inline derived table from this table
*/
@PlainSQL override fun where(condition: SQL): Message = where(DSL.condition(condition))
/**
* Create an inline derived table from this table
*/
@PlainSQL override fun where(@Stringly.SQL condition: String): Message = where(DSL.condition(condition))
/**
* Create an inline derived table from this table
*/
@PlainSQL override fun where(@Stringly.SQL condition: String, vararg binds: Any?): Message = where(DSL.condition(condition, *binds))
/**
* Create an inline derived table from this table
*/
@PlainSQL override fun where(@Stringly.SQL condition: String, vararg parts: QueryPart): Message = where(DSL.condition(condition, *parts))
/**
* Create an inline derived table from this table
*/
override fun whereExists(select: Select<*>): Message = where(DSL.exists(select))
/**
* Create an inline derived table from this table
*/
override fun whereNotExists(select: Select<*>): Message = where(DSL.notExists(select))
}

View file

@ -1,76 +0,0 @@
/*
* This file is generated by jOOQ.
*/
package at.dokkae.homepage.generated.jooq.tables.pojos
import java.io.Serializable
import java.time.OffsetDateTime
import java.util.UUID
/**
* This class is generated by jOOQ.
*/
@Suppress("warnings")
data class Message(
val id: UUID,
val author: String,
val content: String,
val createdAt: OffsetDateTime? = null,
val updatedAt: OffsetDateTime? = null
): Serializable {
override fun equals(other: Any?): Boolean {
if (this === other)
return true
if (other == null)
return false
if (this::class != other::class)
return false
val o: Message = other as Message
if (this.id != o.id)
return false
if (this.author != o.author)
return false
if (this.content != o.content)
return false
if (this.createdAt == null) {
if (o.createdAt != null)
return false
}
else if (this.createdAt != o.createdAt)
return false
if (this.updatedAt == null) {
if (o.updatedAt != null)
return false
}
else if (this.updatedAt != o.updatedAt)
return false
return true
}
override fun hashCode(): Int {
val prime = 31
var result = 1
result = prime * result + this.id.hashCode()
result = prime * result + this.author.hashCode()
result = prime * result + this.content.hashCode()
result = prime * result + (if (this.createdAt == null) 0 else this.createdAt.hashCode())
result = prime * result + (if (this.updatedAt == null) 0 else this.updatedAt.hashCode())
return result
}
override fun toString(): String {
val sb = StringBuilder("Message (")
sb.append(id)
sb.append(", ").append(author)
sb.append(", ").append(content)
sb.append(", ").append(createdAt)
sb.append(", ").append(updatedAt)
sb.append(")")
return sb.toString()
}
}

View file

@ -1,73 +0,0 @@
/*
* This file is generated by jOOQ.
*/
package at.dokkae.homepage.generated.jooq.tables.records
import at.dokkae.homepage.generated.jooq.tables.Message
import java.time.OffsetDateTime
import java.util.UUID
import org.jooq.Record1
import org.jooq.impl.UpdatableRecordImpl
/**
* This class is generated by jOOQ.
*/
@Suppress("warnings")
open class MessageRecord private constructor() : UpdatableRecordImpl<MessageRecord>(Message.MESSAGE) {
open var id: UUID
set(value): Unit = set(0, value)
get(): UUID = get(0) as UUID
open var author: String
set(value): Unit = set(1, value)
get(): String = get(1) as String
open var content: String
set(value): Unit = set(2, value)
get(): String = get(2) as String
open var createdAt: OffsetDateTime?
set(value): Unit = set(3, value)
get(): OffsetDateTime? = get(3) as OffsetDateTime?
open var updatedAt: OffsetDateTime?
set(value): Unit = set(4, value)
get(): OffsetDateTime? = get(4) as OffsetDateTime?
// -------------------------------------------------------------------------
// Primary key information
// -------------------------------------------------------------------------
override fun key(): Record1<UUID?> = super.key() as Record1<UUID?>
/**
* Create a detached, initialised MessageRecord
*/
constructor(id: UUID, author: String, content: String, createdAt: OffsetDateTime? = null, updatedAt: OffsetDateTime? = null): this() {
this.id = id
this.author = author
this.content = content
this.createdAt = createdAt
this.updatedAt = updatedAt
resetTouchedOnNotNull()
}
/**
* Create a detached, initialised MessageRecord
*/
constructor(value: at.dokkae.homepage.generated.jooq.tables.pojos.Message?): this() {
if (value != null) {
this.id = value.id
this.author = value.author
this.content = value.content
this.createdAt = value.createdAt
this.updatedAt = value.updatedAt
resetTouchedOnNotNull()
}
}
}

View file

@ -1,15 +0,0 @@
@file:Suppress("warnings")
/*
* This file is generated by jOOQ.
*/
package at.dokkae.homepage.generated.jooq.tables.references
import at.dokkae.homepage.generated.jooq.tables.Message
/**
* The table <code>public.message</code>.
*/
val MESSAGE: Message = Message.MESSAGE

View file

@ -1,2 +1,4 @@
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=false org.gradle.configuration-cache=true
kotlin.incremental=true
kotlin.daemon.jvmargs=-Xmx4g

View file

@ -4,7 +4,7 @@ shadow = "9.3.0"
dotenv-plugin = "1.1.3" dotenv-plugin = "1.1.3"
dotenv = "6.5.1" dotenv = "6.5.1"
http4k = "6.23.1.0" http4k = "6.23.1.0"
jte = "3.2.1" kotlinx-html = "0.12.0"
flyway = "11.19.0" flyway = "11.19.0"
jooq = "3.20.10" jooq = "3.20.10"
junit = "6.0.0" junit = "6.0.0"
@ -15,7 +15,6 @@ tasktree = "4.0.1"
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
dotenv-plugin = { id = "io.github.klahap.dotenv", version.ref = "dotenv-plugin" } dotenv-plugin = { id = "io.github.klahap.dotenv", version.ref = "dotenv-plugin" }
jte = { id = "gg.jte.gradle", version.ref = "jte" }
jooq-codegen-gradle = { id = "org.jooq.jooq-codegen-gradle", version.ref = "jooq" } jooq-codegen-gradle = { id = "org.jooq.jooq-codegen-gradle", version.ref = "jooq" }
tasktree = { id = "com.dorongold.task-tree", version.ref = "tasktree" } tasktree = { id = "com.dorongold.task-tree", version.ref = "tasktree" }
flyway = { id = "org.flywaydb.flyway", version.ref = "flyway" } flyway = { id = "org.flywaydb.flyway", version.ref = "flyway" }
@ -25,10 +24,14 @@ http4k = [
"http4k-client-okhttp", "http4k-client-okhttp",
"http4k-core", "http4k-core",
"http4k-server-jetty", "http4k-server-jetty",
"http4k-template-jte",
"http4k-web-htmx" "http4k-web-htmx"
] ]
templating = [
"kotlinx-html",
"kotlinx-html-jvm"
]
testing = [ testing = [
"http4k-testing-approval", "http4k-testing-approval",
"http4k-testing-hamkrest", "http4k-testing-hamkrest",
@ -54,11 +57,11 @@ http4k-bom = { module = "org.http4k:http4k-bom", version.ref = "http4k" }
http4k-client-okhttp = { module = "org.http4k:http4k-client-okhttp" } http4k-client-okhttp = { module = "org.http4k:http4k-client-okhttp" }
http4k-core = { module = "org.http4k:http4k-core" } http4k-core = { module = "org.http4k:http4k-core" }
http4k-server-jetty = { module = "org.http4k:http4k-server-jetty" } http4k-server-jetty = { module = "org.http4k:http4k-server-jetty" }
http4k-template-jte = { module = "org.http4k:http4k-template-jte" }
http4k-web-htmx = { module = "org.http4k:http4k-web-htmx" } http4k-web-htmx = { module = "org.http4k:http4k-web-htmx" }
# JTE Templating # HTML Templating
jte-kotlin = { module = "gg.jte:jte-kotlin", version.ref = "jte" } 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 # Database Driver
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" } postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }

View file

@ -1,35 +0,0 @@
@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);
}
}
}

View file

@ -1,46 +0,0 @@
@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);
}
}
}

View file

@ -1,34 +1,48 @@
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.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.IndexTemplate import at.dokkae.homepage.templates.page.chatPage
import at.dokkae.homepage.templates.MessageTemplate import at.dokkae.homepage.templates.partials.messagePartial
import io.github.cdimascio.dotenv.dotenv 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.flywaydb.core.Flyway
import org.http4k.core.ContentType
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
import org.http4k.core.Method.* import org.http4k.core.Method.*
import org.http4k.core.MimeTypes
import org.http4k.core.PolyHandler
import org.http4k.core.Response import org.http4k.core.Response
import org.http4k.core.Status 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.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.ResourceLoader
import org.http4k.routing.RoutingHandler
import org.http4k.routing.bindHttp import org.http4k.routing.bindHttp
import org.http4k.routing.bindSse import org.http4k.routing.bindSse
import org.http4k.routing.path
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.routing.static
import org.http4k.server.Jetty import org.http4k.server.Jetty
import org.http4k.server.PolyServerConfig
import org.http4k.server.ServerConfig
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.jooq.SQLDialect import org.jooq.SQLDialect
import org.jooq.impl.DSL import org.jooq.impl.DSL
import java.sql.DriverManager import java.sql.DriverManager
@ -39,7 +53,7 @@ import kotlin.concurrent.thread
fun migrateDatabase(env: Environment) { fun migrateDatabase(env: Environment) {
val flyway = Flyway.configure() 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") .locations("classpath:db/migration")
.baselineOnMigrate(true) // optional: creates baseline if no schema history exists .baselineOnMigrate(true) // optional: creates baseline if no schema history exists
.load() .load()
@ -48,48 +62,49 @@ fun migrateDatabase(env: Environment) {
println("Migrated ${result.migrationsExecuted} migration${if (result.migrationsExecuted != 1) "s" else ""}") 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() { fun main() {
val env = Environment.load(dotenv { val env = Environment.load(dotenv {
ignoreIfMissing = true ignoreIfMissing = true
ignoreIfMalformed = true ignoreIfMalformed = true
}) })
if (env.dbMigrate) { if (env.db.migrate) {
migrateDatabase(env) 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 dslContext = DSL.using(connection, SQLDialect.POSTGRES)
val messageRepository: MessageRepository = JooqMessageRepository(dslContext) val messageRepository: MessageRepository = JooqMessageRepository(dslContext)
val subscribers = CopyOnWriteArrayList<Sse>() 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 = { 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( val sse = sse(
@ -97,13 +112,16 @@ fun main() {
SseResponse { sse -> SseResponse { sse ->
subscribers.add(sse) subscribers.add(sse)
sse.send(SseMessage.Event("connected", "Connection established"))
sse.onClose { subscribers.remove(sse) } sse.onClose { subscribers.remove(sse) }
} }
} }
) )
val http = routes( 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, "/" bindHttp GET to indexHandler,
@ -117,7 +135,9 @@ 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(MessageTemplate(msg))) val sseMsg = SseMessage.Data(createHTML().div {
messagePartial(msg)
})
messageRepository.save(msg) messageRepository.save(msg)
subscribers.forEach { 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}")
} }

View file

@ -8,27 +8,40 @@ enum class Env {
} }
data class Environment( data class Environment(
val appPort: Int, val app: AppEnvironment,
val appDomain: String, val db: DbEnvironment,
val appEnv: Env,
val dbUrl: String,
val dbUsername: String,
val dbPassword: String,
val dbMigrate: Boolean,
) { ) {
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 { companion object {
/** /**
* Returns a loaded Environment object instance. * Returns a loaded Environment object instance.
* @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(
appPort = requireEnv(dotenv, "APP_PORT").toInt(), app = AppEnvironment(
appDomain = requireEnv(dotenv, "APP_DOMAIN"), port = requireEnv(dotenv, "APP_PORT").toInt(),
appEnv = Env.valueOf(requireEnv(dotenv, "APP_ENV").uppercase()), domain = requireEnv(dotenv, "APP_DOMAIN"),
dbUrl = requireEnv(dotenv, "DB_URL"), env = Env.valueOf(requireEnv(dotenv, "APP_ENV").uppercase()),
dbUsername = requireEnv(dotenv, "DB_USERNAME"), ),
dbPassword = requireEnv(dotenv, "DB_PASSWORD"), db = DbEnvironment(
dbMigrate = dotenv["DB_MIGRATE"]?.toBoolean() ?: false 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 { private fun requireEnv(dotenv: Dotenv, key: String): String {

View file

@ -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)
}

View 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" }
}
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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()
}
}

View 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"
}
}
}
}
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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)
}
}
}

View 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}})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long