Compare commits

..

10 commits

Author SHA1 Message Date
a80473fb35 fix: forgot to run codegen on jte
Some checks failed
Build and Publish Docker Image / build (push) Has been cancelled
Build and Publish Docker Image / deploy (push) Has been cancelled
2025-12-14 03:48:09 +01:00
ec7e116a2b feat: ui rework 2025-12-14 03:40:05 +01:00
388dc63a12 fix: updated_at set to null on insert
before it was being set to current_timestamp which was a bug
2025-12-14 01:00:27 +01:00
b2af414cfb fix: flyway path resolution issue 2025-12-14 00:18:51 +01:00
2623ec7643 feat: dokploy deployment api call 2025-12-14 00:06:42 +01:00
df1870121f feat: add codegen files 2025-12-13 23:40:19 +01:00
5eb6fc4f78 fix: wronng gitignore exception for codegen 2025-12-13 23:38:15 +01:00
51cea5d0fe feat: move codegen out of docker builder 2025-12-13 23:33:17 +01:00
e798f36851 fix: gh-action db 5 2025-12-13 23:09:11 +01:00
f7faa37beb fix: gh-action db 4 2025-12-13 22:59:48 +01:00
33 changed files with 895 additions and 174 deletions

View file

@ -1,5 +1,6 @@
PORT=9000
HOST=localhost
APP_PORT=9000
APP_DOMAIN=localhost
APP_ENV=development
DB_URL=jdbc:postgresql://localhost:5432/homepage
DB_USERNAME=postgres

View file

@ -21,7 +21,7 @@ jobs:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5445:5432
- 5432:5432
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=5s
@ -39,6 +39,8 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
@ -60,24 +62,29 @@ jobs:
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Wait for PostgreSQL
run: |
until pg_isready -h localhost -p 5445 -U postgres; do
echo "Waiting for postgres..."
sleep 2
done
- name: Build and publish Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
network: host
build-args: |
DB_URL=jdbc:postgresql://localhost:5445/homepage
DB_USERNAME=postgres
DB_PASSWORD=postgres
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
runs-on: ubuntu-latest
needs: build
if: success()
steps:
- name: Trigger Dokploy deployment
run: |
curl -X 'POST' \
'http://5.180.253.47:3000/api/application.deploy' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-H 'x-api-key: ${{ secrets.DOKPLOY_API_KEY }}' \
-d '{
"applicationId": "${{ vars.DOKPLOY_APP_ID }}"
}'

4
.gitignore vendored
View file

@ -56,6 +56,10 @@ gradle-app.setting
# JDT-specific (Eclipse Java Development Tools)
.classpath
# Allow generated code fragments for Docker builds
!build/generated-src/**
!build/generated-resources/**
###################
### Environment ###
###################

View file

@ -1,13 +1,6 @@
# --- Stage 1: Build the JAR ---
FROM gradle:9.2.1-jdk21 AS build
ARG DB_URL
ARG DB_USERNAME
ARG DB_PASSWORD
ENV DB_URL=${DB_URL}
ENV DB_USERNAME=${DB_USERNAME}
ENV DB_PASSWORD=${DB_PASSWORD}
# Set working dir
WORKDIR /app
@ -18,8 +11,12 @@ COPY --chown=gradle:gradle gradle ./gradle
# Copy source code
COPY --chown=gradle:gradle src ./src
# Build the fat jar
RUN ./gradlew clean build --no-daemon
# Copy pre-generated code fragments
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)
RUN ./gradlew build -x clean -x cleanGenerated -x jooqCodegen -x flywayMigrate -x precompileJte --no-daemon
# --- Stage 2: Run the app ---
FROM eclipse-temurin:21-jdk-alpine

View file

@ -269,7 +269,12 @@ tasks.named<ShadowJar>("shadowJar") {
archiveFileName.set("app.jar")
mergeServiceFiles()
mergeServiceFiles {
include("META-INF/services/**")
// Fix: https://github.com/flyway/flyway/issues/4170#issuecomment-3569762563
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
exclude(
"META-INF/*.RSA",
@ -296,7 +301,3 @@ tasks.register("cleanGenerated") {
logger.lifecycle("✓ Cleaned generated code directories")
}
}
tasks.named("clean") {
dependsOn("cleanGenerated")
}

View file

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

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

@ -0,0 +1,43 @@
/*
* 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

@ -0,0 +1,40 @@
/*
* 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

@ -0,0 +1,21 @@
@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

@ -0,0 +1,187 @@
/*
* 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

@ -0,0 +1,76 @@
/*
* 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

@ -0,0 +1,73 @@
/*
* 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

@ -0,0 +1,15 @@
@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

Binary file not shown.

View file

@ -0,0 +1,35 @@
@file:Suppress("ktlint")
package gg.jte.generated.ondemand
import at.dokkae.homepage.templates.IndexTemplate
import at.dokkae.homepage.templates.MessageTemplate
import gg.jte.support.ForSupport
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
class JteIndexGenerated {
companion object {
@JvmField val JTE_NAME = "Index.kte"
@JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,4,4,4,4,4,18,18,37,58,88,93,96,96,97,97,98,98,102,108,119,132,146,161,161,161,4,4,4,4,4)
@JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:IndexTemplate) {
jteOutput.writeContent("\n<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>Dokkae's Chat</title>\n\n <script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js\" integrity=\"sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4\" integrity=\"sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N\" crossorigin=\"anonymous\"></script>\n <script src=\"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4\"></script>\n\n <style>\n ")
jteOutput.writeContent("\n .scrollbar-custom::-webkit-scrollbar {\n width: 8px;\n }\n\n .scrollbar-custom::-webkit-scrollbar-track {\n background: #1a1a1a;\n border-radius: 4px;\n }\n\n .scrollbar-custom::-webkit-scrollbar-thumb {\n background: #444;\n border-radius: 4px;\n }\n\n .scrollbar-custom::-webkit-scrollbar-thumb:hover {\n background: #555;\n }\n\n ")
jteOutput.writeContent("\n @keyframes slideIn {\n from {\n opacity: 0;\n transform: translateY(20px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n }\n\n .animate-slide-in.htmx-added {\n opacity: 0;\n }\n\n .animate-slide-in {\n opacity: 1;\n animation: slideIn 0.3s ease-out;\n }\n\n ")
jteOutput.writeContent("\n .message-border-red {\n background-color: #ffb3ba !important;\n }\n\n .message-border-orange {\n background-color: #ffdfba !important;\n }\n\n .message-border-yellow {\n background-color: #ffffba !important;\n }\n\n .message-border-green {\n background-color: #baffc9 !important;\n }\n\n .message-border-blue {\n background-color: #bae1ff !important;\n }\n\n .message-border-pink {\n background-color: #fddfdf !important;\n }\n\n\n </style>\n</head>\n<body hx-ext=\"sse\" class=\"bg-neutral-900 text-neutral-100 min-h-screen overflow-hidden\">\n<main class=\"flex flex-col h-screen max-w-6xl mx-auto px-4 md:px-6\">\n ")
jteOutput.writeContent("\n <header class=\"py-5 border-b border-neutral-800 shrink-0\">\n <h1 class=\"text-xl md:text-2xl font-bold text-white\">Dokkae's Chat</h1>\n </header>\n\n ")
jteOutput.writeContent("\n <div id=\"messages-container\" class=\"flex-1 flex flex-col-reverse overflow-y-auto overflow-x-hidden scrollbar-custom py-4\">\n <div id=\"messages\" class=\"flex flex-col-reverse\" sse-connect=\"/message-events\" sse-swap=\"message\" hx-swap=\"afterbegin\">\n ")
for (message in model.messages) {
jteOutput.writeContent("\n ")
gg.jte.generated.ondemand.partials.JteMessageGenerated.render(jteOutput, jteHtmlInterceptor, MessageTemplate(message));
jteOutput.writeContent("\n ")
}
jteOutput.writeContent("\n </div>\n </div>\n\n ")
jteOutput.writeContent("\n <form class=\"bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 mb-4 mt-2 shrink-0\"\n hx-post=\"/messages\"\n hx-swap=\"none\"\n hx-on::after-request=\"if(event.detail.successful)document.getElementById('message-input').value = ''\">\n <div class=\"flex flex-col md:flex-row gap-3\">\n ")
jteOutput.writeContent("\n <div class=\"flex-1 md:flex-none md:w-48\">\n <div class=\"relative\">\n <input id=\"username-input\"\n type=\"text\"\n name=\"author\"\n placeholder=\"Name (optional)\"\n class=\"w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition\">\n </div>\n </div>\n\n ")
jteOutput.writeContent("\n <div class=\"flex-1\">\n <div class=\"flex flex-row gap-3\">\n <div class=\"relative flex-1\">\n <input id=\"message-input\"\n type=\"text\"\n name=\"message\"\n placeholder=\"Your message...\"\n required\n autocomplete=\"off\"\n class=\"w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition\">\n </div>\n\n ")
jteOutput.writeContent("\n <button type=\"submit\"\n class=\"bg-rose-200 hover:bg-rose-300 text-black font-semibold py-3 px-6 rounded-lg transition duration-200 flex items-center justify-center gap-2\">\n <svg width=\"24px\" height=\"24px\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n <g id=\"Communication / Paper_Plane\">\n <path id=\"Vector\" d=\"M10.3078 13.6923L15.1539 8.84619M20.1113 5.88867L16.0207 19.1833C15.6541 20.3747 15.4706 20.9707 15.1544 21.1683C14.8802 21.3396 14.5406 21.3683 14.2419 21.2443C13.8975 21.1014 13.618 20.5433 13.0603 19.428L10.4694 14.2461C10.3809 14.0691 10.3366 13.981 10.2775 13.9043C10.225 13.8363 10.1645 13.7749 10.0965 13.7225C10.0215 13.6647 9.93486 13.6214 9.76577 13.5369L4.57192 10.9399C3.45662 10.3823 2.89892 10.1032 2.75601 9.75879C2.63207 9.4601 2.66033 9.12023 2.83169 8.84597C3.02928 8.52974 3.62523 8.34603 4.81704 7.97932L18.1116 3.88867C19.0486 3.60038 19.5173 3.45635 19.8337 3.57253C20.1094 3.67373 20.3267 3.89084 20.4279 4.16651C20.544 4.48283 20.3999 4.95126 20.1119 5.88729L20.1113 5.88867Z\" stroke=\"#000000\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n </g>\n </svg>\n </button>\n </div>\n </div>\n </div>\n </form>\n\n ")
jteOutput.writeContent("\n <footer class=\"border-t border-neutral-800 py-4 shrink-0\">\n <p class=\"text-sm text-neutral-500 text-center\">\n No auth — anyone can post. Open source at\n <a href=\"https://github.com/dokkae6949/homepage\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n class=\"text-blue-400 hover:text-blue-300 hover:underline transition\">\n dokkae6949/homepage\n </a>\n </p>\n </footer>\n</main>\n\n</body>\n</html>")
}
@JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map<String, Any?>) {
val model = params["model"] as IndexTemplate
render(jteOutput, jteHtmlInterceptor, model);
}
}
}

View file

@ -0,0 +1,46 @@
@file:Suppress("ktlint")
package gg.jte.generated.ondemand.partials
import at.dokkae.homepage.templates.MessageTemplate
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.math.absoluteValue
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
class JteMessageGenerated {
companion object {
@JvmField val JTE_NAME = "partials/Message.kte"
@JvmField val JTE_LINE_INFO = intArrayOf(0,0,0,1,2,3,4,6,6,6,6,6,8,8,8,9,9,10,10,14,15,15,15,15,18,21,21,21,24,24,24,24,24,24,28,30,30,30,34,34,34,6,6,6,6,6)
@JvmStatic fun render(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, model:MessageTemplate) {
jteOutput.writeContent("\n")
val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(ZoneId.systemDefault())
jteOutput.writeContent("\n")
val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())
jteOutput.writeContent("\n")
val borderColors = listOf("red", "orange", "yellow", "green", "blue", "pink" )
jteOutput.writeContent("\n\n<div class=\"message-group mb-3 animate-slide-in\">\n <div class=\"flex relative px-3 py-1 hover:bg-neutral-800/30 rounded transition-colors\">\n ")
jteOutput.writeContent("\n <div class=\"absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3/4 rounded-r message-border-")
jteOutput.setContext("div", "class")
jteOutput.writeUserContent(borderColors[model.message.id.hashCode().absoluteValue % borderColors.size])
jteOutput.setContext("div", null)
jteOutput.writeContent("\"></div>\n\n <div class=\"flex-1 pl-3 text-ellipsis text-wrap break-all\">\n ")
jteOutput.writeContent("\n <div class=\"flex flex-wrap items-baseline gap-2 mb-1\">\n <span class=\"font-semibold text-white\">\n ")
jteOutput.setContext("span", null)
jteOutput.writeUserContent(model.message.author)
jteOutput.writeContent("\n </span>\n <span class=\"text-xs text-neutral-400\">\n ")
jteOutput.setContext("span", null)
jteOutput.writeUserContent(dateFormatter.format(model.message.createdAt))
jteOutput.writeContent("")
jteOutput.setContext("span", null)
jteOutput.writeUserContent(timeFormatter.format(model.message.createdAt))
jteOutput.writeContent("\n </span>\n </div>\n\n ")
jteOutput.writeContent("\n <div class=\"text-neutral-200\">\n ")
jteOutput.setContext("div", null)
jteOutput.writeUserContent(model.message.content)
jteOutput.writeContent("\n </div>\n </div>\n </div>\n</div>")
}
@JvmStatic fun renderMap(jteOutput:gg.jte.html.HtmlTemplateOutput, jteHtmlInterceptor:gg.jte.html.HtmlInterceptor?, params:Map<String, Any?>) {
val model = params["model"] as MessageTemplate
render(jteOutput, jteHtmlInterceptor, model);
}
}
}

View file

@ -1,10 +1,12 @@
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.Index
import at.dokkae.homepage.templates.IndexTemplate
import at.dokkae.homepage.templates.MessageTemplate
import io.github.cdimascio.dotenv.dotenv
import org.flywaydb.core.Flyway
import org.http4k.core.HttpHandler
@ -14,18 +16,19 @@ import org.http4k.core.Status
import org.http4k.core.body.form
import org.http4k.core.getFirst
import org.http4k.core.toParametersMap
import org.http4k.routing.ResourceLoader
import org.http4k.routing.bindHttp
import org.http4k.routing.bindSse
import org.http4k.routing.poly
import org.http4k.routing.routes
import org.http4k.routing.sse
import org.http4k.routing.static
import org.http4k.server.Jetty
import org.http4k.server.asServer
import org.http4k.sse.Sse
import org.http4k.sse.SseMessage
import org.http4k.sse.SseResponse
import org.http4k.template.JTETemplates
import org.http4k.template.ViewModel
import org.jooq.SQLDialect
import org.jooq.impl.DSL
import java.sql.DriverManager
@ -52,14 +55,11 @@ data class Message(
val id: UUID = UUID.randomUUID(),
val createdAt: Instant = Instant.now(),
val updatedAt: Instant? = null
) : ViewModel {
) {
init {
require(author.length <= 31) { "Author must be 31 characters or less" }
require(content.length <= 255) { "Content must be 255 characters or less" }
}
override fun template(): String = "partials/Message"
}
fun main() {
@ -77,10 +77,19 @@ fun main() {
val messageRepository: MessageRepository = JooqMessageRepository(dslContext)
val subscribers = CopyOnWriteArrayList<Sse>()
val renderer = JTETemplates().Precompiled("build/generated-resources/jte")
val renderer = when (env.appEnv) {
Env.DEVELOPMENT -> {
println("🔥 Hot-Reloading JTE templates")
JTETemplates().HotReload("src/main/kte")
}
Env.PRODUCTION -> {
println("📦 Loading pre-compiled JTE templates")
JTETemplates().Precompiled("build/generated-resources/jte")
}
}
val indexHandler: HttpHandler = {
Response(Status.OK).body(renderer(Index(messageRepository.findAll())))
Response(Status.OK).body(renderer(IndexTemplate(messageRepository.findAll())))
}
val sse = sse(
@ -94,9 +103,12 @@ fun main() {
)
val http = routes(
static(ResourceLoader.Classpath("static")),
"/" bindHttp GET to indexHandler,
"/messages" bindHttp POST to { req ->
try {
val params = req.form().toParametersMap()
val author = params.getFirst("author").takeIf { !it.isNullOrBlank() } ?: "Anonymous"
val message = params.getFirst("message")
@ -105,7 +117,7 @@ fun main() {
Response(Status.BAD_REQUEST)
} else {
val msg = Message(author, message)
val sseMsg = SseMessage.Data(renderer(msg))
val sseMsg = SseMessage.Data(renderer(MessageTemplate(msg)))
messageRepository.save(msg)
subscribers.forEach {
@ -114,10 +126,15 @@ fun main() {
Response(Status.CREATED)
}
} catch (ex: Exception) {
println("Failed to receive message: ${ex.toString()} ${ex.message}")
Response(Status.INTERNAL_SERVER_ERROR)
}
}
)
poly(http, sse).asServer(Jetty(port = env.port)).start()
poly(http, sse).asServer(Jetty(port = env.appPort)).start()
println("Server started on http://${env.host}:${env.port}")
println("Server started on http://${env.appDomain}:${env.appPort}")
}

View file

@ -2,9 +2,15 @@ package at.dokkae.homepage.config
import io.github.cdimascio.dotenv.Dotenv
enum class Env {
DEVELOPMENT,
PRODUCTION,
}
data class Environment(
val port: Int,
val host: String,
val appPort: Int,
val appDomain: String,
val appEnv: Env,
val dbUrl: String,
val dbUsername: String,
val dbPassword: String,
@ -16,8 +22,9 @@ data class Environment(
* @throws IllegalStateException if required environment variables were not found within the provided `dotenv` instance.
*/
fun load(dotenv: Dotenv): Environment = Environment(
port = requireEnv(dotenv, "PORT").toInt(),
host = requireEnv(dotenv, "HOST"),
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"),

View file

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

View file

@ -0,0 +1,8 @@
package at.dokkae.homepage.templates
import at.dokkae.homepage.Message
import org.http4k.template.ViewModel
data class MessageTemplate(val message: Message) : ViewModel {
override fun template(): String = "partials/Message"
}

View file

@ -1,159 +1,162 @@
@import at.dokkae.homepage.templates.Index
@import at.dokkae.homepage.templates.IndexTemplate
@import at.dokkae.homepage.templates.MessageTemplate
@import gg.jte.support.ForSupport
@param model: Index
@param model: IndexTemplate
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Simple Chat — http4k + JTE + htmx</title>
<title>Dokkae's Chat</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4" integrity="sha384-A986SAtodyH8eg8x8irJnYUk7i9inVQqYigD6qZ9evobksGNIXfeFvDwLSHcp31N" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style>
:root {
--bg: #f5f5f7;
--card: #ffffff;
--border: #d0d0d5;
--bubble-self: #daf0ff;
--bubble-other: #ececec;
--text-dark: #222;
--text-light: #666;
--radius: 12px;
--spacing: 12px;
--font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Custom scrollbar */
.scrollbar-custom::-webkit-scrollbar {
width: 8px;
}
body {
margin: 0;
font-family: var(--font);
background: var(--bg);
display: flex;
justify-content: center;
padding: 32px 12px;
.scrollbar-custom::-webkit-scrollbar-track {
background: #1a1a1a;
border-radius: 4px;
}
#chat {
width: 100%;
max-width: 560px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
.scrollbar-custom::-webkit-scrollbar-thumb {
background: #444;
border-radius: 4px;
}
h1 {
margin: 0 0 16px;
font-size: 1.4rem;
color: var(--text-dark);
text-align: center;
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #555;
}
#messages {
display: flex;
flex-direction: column-reverse;
gap: var(--spacing);
margin-bottom: 20px;
max-height: 60vh;
overflow-y: auto;
padding-right: 4px;
/* Animation for new messages */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Nice scrollbar */
#messages::-webkit-scrollbar { width: 6px; }
#messages::-webkit-scrollbar-thumb {
background: #bbb;
border-radius: 3px;
.animate-slide-in.htmx-added {
opacity: 0;
}
.message {
padding: 10px 14px;
border-radius: var(--radius);
max-width: 85%;
font-size: 0.95rem;
line-height: 1.35;
color: var(--text-dark);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
.animate-slide-in {
opacity: 1;
animation: slideIn 0.3s ease-out;
}
.message-author {
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-light);
/* Different border colors */
.message-border-red {
background-color: #ffb3ba !important;
}
.message-self {
align-self: flex-end;
background: var(--bubble-self);
.message-border-orange {
background-color: #ffdfba !important;
}
.message-other {
align-self: flex-start;
background: var(--bubble-other);
.message-border-yellow {
background-color: #ffffba !important;
}
form {
display: flex;
gap: 8px;
margin-bottom: 12px;
.message-border-green {
background-color: #baffc9 !important;
}
input[type="text"] {
flex: 1;
padding: 10px;
font-size: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
.message-border-blue {
background-color: #bae1ff !important;
}
button {
padding: 10px 16px;
background: #1e88ff;
border: none;
border-radius: var(--radius);
color: white;
font-size: 1rem;
cursor: pointer;
transition: 0.2s;
.message-border-pink {
background-color: #fddfdf !important;
}
button:hover {
background: #0c74e8;
}
p.note {
font-size: 0.82rem;
color: var(--text-light);
margin-top: 14px;
text-align: center;
}
</style>
</head>
<body hx-ext="sse">
<main id="chat">
<h1>Simple Chat</h1>
<body hx-ext="sse" class="bg-neutral-900 text-neutral-100 min-h-screen overflow-hidden">
<main class="flex flex-col h-screen max-w-6xl mx-auto px-4 md:px-6">
<!-- Header -->
<header class="py-5 border-b border-neutral-800 shrink-0">
<h1 class="text-xl md:text-2xl font-bold text-white">Dokkae's Chat</h1>
</header>
<div id="messages" sse-connect="/message-events" sse-swap="message" hx-swap="afterbegin">
@for (message in model.messages.reversed())
@template.partials.Message(message)
<!-- Messages Container - CSS-only scroll to bottom -->
<div id="messages-container" class="flex-1 flex flex-col-reverse overflow-y-auto overflow-x-hidden scrollbar-custom py-4">
<div id="messages" class="flex flex-col-reverse" sse-connect="/message-events" sse-swap="message" hx-swap="afterbegin">
@for (message in model.messages)
@template.partials.Message(MessageTemplate(message))
@endfor
</div>
</div>
<!-- Message Form -->
<form class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 mb-4 mt-2 shrink-0"
hx-post="/messages"
hx-swap="none"
hx-on::after-request="if(event.detail.successful)document.getElementById('message-input').value = ''">
<div class="flex flex-col md:flex-row gap-3">
<!-- Username input -->
<div class="flex-1 md:flex-none md:w-48">
<div class="relative">
<input id="username-input"
type="text"
name="author"
placeholder="Name (optional)"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
</div>
</div>
<form hx-post="/messages" hx-swap="none" hx-on::after-request="if(event.detail.successful)document.getElementById('message-input').value = ''">
<input id="username-input" type="text" name="author" placeholder="name (optional)">
<input id="message-input" type="text" name="message" placeholder="your message" required>
<button type="submit">
Send
<!-- Message input -->
<div class="flex-1">
<div class="flex flex-row gap-3">
<div class="relative flex-1">
<input id="message-input"
type="text"
name="message"
placeholder="Your message..."
required
autocomplete="off"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg py-3 px-4 text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
</div>
<!-- Send button -->
<button type="submit"
class="bg-rose-200 hover:bg-rose-300 text-black font-semibold py-3 px-6 rounded-lg transition duration-200 flex items-center justify-center gap-2">
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Communication / Paper_Plane">
<path id="Vector" d="M10.3078 13.6923L15.1539 8.84619M20.1113 5.88867L16.0207 19.1833C15.6541 20.3747 15.4706 20.9707 15.1544 21.1683C14.8802 21.3396 14.5406 21.3683 14.2419 21.2443C13.8975 21.1014 13.618 20.5433 13.0603 19.428L10.4694 14.2461C10.3809 14.0691 10.3366 13.981 10.2775 13.9043C10.225 13.8363 10.1645 13.7749 10.0965 13.7225C10.0215 13.6647 9.93486 13.6214 9.76577 13.5369L4.57192 10.9399C3.45662 10.3823 2.89892 10.1032 2.75601 9.75879C2.63207 9.4601 2.66033 9.12023 2.83169 8.84597C3.02928 8.52974 3.62523 8.34603 4.81704 7.97932L18.1116 3.88867C19.0486 3.60038 19.5173 3.45635 19.8337 3.57253C20.1094 3.67373 20.3267 3.89084 20.4279 4.16651C20.544 4.48283 20.3999 4.95126 20.1119 5.88729L20.1113 5.88867Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>
</button>
</div>
</div>
</div>
</form>
<p style="font-size: .9rem; color: #666">No auth — anyone can post. Messages are stored only in memory.</p>
<!-- Footer -->
<footer class="border-t border-neutral-800 py-4 shrink-0">
<p class="text-sm text-neutral-500 text-center">
No auth — anyone can post. Open source at
<a href="https://github.com/dokkae6949/homepage"
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-300 hover:underline transition">
dokkae6949/homepage
</a>
</p>
</footer>
</main>
</body>
</html>

View file

@ -1,11 +1,35 @@
@import at.dokkae.homepage.Message
@import at.dokkae.homepage.templates.MessageTemplate
@import java.time.Instant
@import java.time.ZoneId
@import java.time.format.DateTimeFormatter
@import kotlin.math.absoluteValue
@param message: Message
@param model: MessageTemplate
<div class="message">
<strong>${message.author}</strong>:
${message.content}
<span style="color:#888; font-size:.8rem;">
(${message.createdAt.toString()})
!{val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(ZoneId.systemDefault())}
!{val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault())}
!{val borderColors = listOf("red", "orange", "yellow", "green", "blue", "pink" )}
<div class="message-group mb-3 animate-slide-in">
<div class="flex relative px-3 py-1 hover:bg-neutral-800/30 rounded transition-colors">
<!-- Left border with rotating colors -->
<div class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3/4 rounded-r message-border-${borderColors[model.message.id.hashCode().absoluteValue % borderColors.size]}"></div>
<div class="flex-1 pl-3 text-ellipsis text-wrap break-all">
<!-- Header with username and timestamp -->
<div class="flex flex-wrap items-baseline gap-2 mb-1">
<span class="font-semibold text-white">
${model.message.author}
</span>
<span class="text-xs text-neutral-400">
${dateFormatter.format(model.message.createdAt)} • ${timeFormatter.format(model.message.createdAt)}
</span>
</div>
<!-- Message content -->
<div class="text-neutral-200">
${model.message.content}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,35 @@
--- Dropping trigger to prevent them from being called while migrating data
drop trigger if exists handle_message_timestamps on message;
--- Migrate data
update message
set updated_at = null
where created_at = updated_at
and updated_at is not null;
--- Replace trigger handler
create or replace function handle_timestamps()
returns trigger as
$$
begin
if lower(tg_op) = 'insert' then
new.created_at = coalesce(new.created_at, current_timestamp);
new.updated_at = null;
elsif lower(tg_op) = 'update' then
if new.created_at is distinct from old.created_at then
raise exception 'Direct modification of created_at is not allowed.';
end if;
new.updated_at = current_timestamp;
end if;
return new;
end;
$$ language plpgsql;
--- Reinsert trigger
create trigger handle_message_timestamps
before insert or update
on message
for each row
execute function handle_timestamps();

View file

View file

View file

View file