diff --git a/build.gradle b/build.gradle index c5e9b34..d6a397d 100644 --- a/build.gradle +++ b/build.gradle @@ -83,7 +83,6 @@ kotlin { implementation "io.ktor:ktor-server-netty:$ktor_version" implementation "io.ktor:ktor-auth:$ktor_version" implementation "io.ktor:ktor-server-sessions:$ktor_version" - implementation "io.ktor:ktor-websockets:$ktor_version" implementation "io.ktor:ktor-jackson:$ktor_version" implementation 'org.jetbrains:kotlin-css-jvm:1.0.0-pre.70-kotlin-1.3.21' diff --git a/src/commonMain/kotlin/de/kif/common/Message.kt b/src/commonMain/kotlin/de/kif/common/Message.kt index e239280..2e70a49 100644 --- a/src/commonMain/kotlin/de/kif/common/Message.kt +++ b/src/commonMain/kotlin/de/kif/common/Message.kt @@ -1,30 +1,24 @@ package de.kif.common +import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationStrategy import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule +@Serializable +data class MessageBox( + val timestamp: Long, + val valid: Boolean, + val messages: List +) + @Serializable data class Message( val type: MessageType, val repository: RepositoryType, val id: Long -) { - - fun stringify(): String { - return json.stringify(serializer(), this) - } - - companion object { - private val jsonContext = SerializersModule {} - - val json = Json(context = jsonContext) - - fun parse(data: String): Message { - return json.parse(serializer(), data) - } - } -} +) enum class MessageType { CREATE, UPDATE, DELETE @@ -33,3 +27,12 @@ enum class MessageType { enum class RepositoryType { ROOM, SCHEDULE, TRACK, USER, WORK_GROUP, POST } + +object Serialization { + private val jsonContext = SerializersModule {} + + val json = Json(context = jsonContext) + + fun stringify(serializer: SerializationStrategy, obj: T) = json.stringify(serializer, obj) + fun parse(serializer: DeserializationStrategy, str: String) = json.parse(serializer, str) +} diff --git a/src/commonMain/kotlin/de/kif/common/Search.kt b/src/commonMain/kotlin/de/kif/common/Search.kt index fadef97..184518d 100644 --- a/src/commonMain/kotlin/de/kif/common/Search.kt +++ b/src/commonMain/kotlin/de/kif/common/Search.kt @@ -10,12 +10,12 @@ data class SearchElement( ) { fun stringify(): String { - return Message.json.stringify(serializer(), this) + return Serialization.stringify(serializer(), this) } companion object { fun parse(data: String): SearchElement { - return Message.json.parse(serializer(), data) + return Serialization.parse(serializer(), data) } } } diff --git a/src/jsMain/kotlin/de/kif/frontend/WebSocketClient.kt b/src/jsMain/kotlin/de/kif/frontend/WebSocketClient.kt index 62464af..22cd247 100644 --- a/src/jsMain/kotlin/de/kif/frontend/WebSocketClient.kt +++ b/src/jsMain/kotlin/de/kif/frontend/WebSocketClient.kt @@ -1,68 +1,92 @@ package de.kif.frontend -import de.kif.common.Message +import de.kif.common.MessageBox import de.kif.common.MessageType import de.kif.common.RepositoryType import de.kif.frontend.repository.* -import de.westermann.kwebview.async -import org.w3c.dom.MessageEvent -import org.w3c.dom.WebSocket -import org.w3c.dom.events.Event +import de.westermann.kwebview.clearInterval +import de.westermann.kwebview.createHtmlView +import de.westermann.kwebview.interval +import kotlinx.serialization.DynamicObjectParser +import org.w3c.dom.get +import org.w3c.xhr.XMLHttpRequest +import kotlin.browser.document import kotlin.browser.window -class WebSocketClient() { +class WebSocketClient { private val prefix = js("prefix") + private val url = "$prefix/api/updates" + private val body = document.body ?: createHtmlView() + private val parser = DynamicObjectParser() - private val useSsl = "https" in window.location.protocol - private val wsProtocol = if (useSsl) "wss" else "ws" - private val url = "$wsProtocol://${window.location.host}$prefix/websocket" + private var timestamp = body.dataset["timestamp"]?.toLongOrNull() ?: 0L + private var intervalId: Int? = null - private lateinit var ws: WebSocket - private var reconnect = false + private fun reload() { + val id = intervalId ?: return + clearInterval(id) + intervalId = null - @Suppress("UNUSED_PARAMETER") - private fun onOpen(event: Event) { - console.log("Connected!") - if (reconnect) { - window.location.reload() - } + window.location.reload() } - private fun onMessage(messageEvent: MessageEvent) { - val message = Message.parse(messageEvent.data?.toString() ?: "") + private fun onMessage(messageBox: MessageBox) { + body.classList.remove("offline") - for (handler in messageHandlers) { - if (handler.repository == message.repository) { - when (message.type) { - MessageType.CREATE -> handler.onCreate(message.id) - MessageType.UPDATE -> handler.onUpdate(message.id) - MessageType.DELETE -> handler.onDelete(message.id) + if (messageBox.valid) { + timestamp = messageBox.timestamp + + for (message in messageBox.messages) { + for (handler in messageHandlers) { + if (handler.repository == message.repository) { + when (message.type) { + MessageType.CREATE -> handler.onCreate(message.id) + MessageType.UPDATE -> handler.onUpdate(message.id) + MessageType.DELETE -> handler.onDelete(message.id) + } + } } } + } else { + reload() } } - @Suppress("UNUSED_PARAMETER") - private fun onClose(event: Event) { - console.log("Disconnected!") - reconnect = true - async(1000) { - connect() + private fun onError(code: Int) { + if (!body.classList.contains("offline")) { + console.log("Offline reason: $code") } + body.classList.add("offline") } - @Suppress("UNUSED_PARAMETER") - private fun onError(event: Event) { - console.log("An error occurred!") - } + private fun request() { + val xmlHttpRequest = XMLHttpRequest() - private fun connect() { - ws = WebSocket(url) + xmlHttpRequest.onreadystatechange = { + try { + if (xmlHttpRequest.readyState == 4.toShort()) { + if (xmlHttpRequest.status == 200.toShort() || xmlHttpRequest.status == 304.toShort()) { + val json = JSON.parse(xmlHttpRequest.responseText) - ws.onopen = this::onOpen - ws.onmessage = this::onMessage - ws.onclose = this::onClose - ws.onerror = this::onError + if (json.OK) { + val message = parser.parse(json.data, MessageBox.serializer()) + onMessage(message) + } else { + onError(-1) + } + + } else { + onError(xmlHttpRequest.status.toInt()) + } + } + } catch (e: Exception) { + console.error(e) + onError(-2) + } + } + xmlHttpRequest.open("GET", "$url?timestamp=$timestamp", true) + xmlHttpRequest.overrideMimeType("application/json") + xmlHttpRequest.send() } private val messageHandlers: List = listOf( @@ -75,7 +99,9 @@ class WebSocketClient() { ) init { - connect() + intervalId = interval(500) { + request() + } } } diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/Ajax.kt b/src/jsMain/kotlin/de/kif/frontend/repository/Ajax.kt index a08991d..1f5446b 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/Ajax.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/Ajax.kt @@ -8,10 +8,15 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine import kotlin.js.Promise +interface JsonResponse { + val OK: Boolean + val data: dynamic +} + suspend fun repositoryGet( url: String ): dynamic { - val promise = Promise { resolve, reject -> + val promise = Promise { resolve, reject -> val xhttp = XMLHttpRequest() xhttp.onreadystatechange = { @@ -47,7 +52,7 @@ suspend fun repositoryPost( url: String, data: String? = null ): dynamic { - val promise = Promise { resolve, reject -> + val promise = Promise { resolve, reject -> val xhttp = XMLHttpRequest() xhttp.onreadystatechange = { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/PostRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/PostRepository.kt index aa56570..13c459e 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/PostRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/PostRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.Post import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object PostRepository : Repository { } override suspend fun create(model: Post): Long { - return repositoryPost("$prefix/api/posts", Message.json.stringify(Post.serializer(), model)) + return repositoryPost("$prefix/api/posts", Serialization.stringify(Post.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: Post) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/post/${model.id}", Message.json.stringify(Post.serializer(), model)) + repositoryPost("$prefix/api/post/${model.id}", Serialization.stringify(Post.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/RoomRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/RoomRepository.kt index 1dc88ee..9d933f3 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/RoomRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/RoomRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.Room import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object RoomRepository : Repository { } override suspend fun create(model: Room): Long { - return repositoryPost("$prefix/api/rooms", Message.json.stringify(Room.serializer(), model)) + return repositoryPost("$prefix/api/rooms", Serialization.stringify(Room.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: Room) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/room/${model.id}", Message.json.stringify(Room.serializer(), model)) + repositoryPost("$prefix/api/room/${model.id}", Serialization.stringify(Room.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/ScheduleRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/ScheduleRepository.kt index 60a2b73..3da61bf 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/ScheduleRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/ScheduleRepository.kt @@ -1,9 +1,6 @@ package de.kif.frontend.repository -import de.kif.common.ConstraintMap -import de.kif.common.Message -import de.kif.common.Repository -import de.kif.common.RepositoryType +import de.kif.common.* import de.kif.common.model.Schedule import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -31,13 +28,13 @@ object ScheduleRepository : Repository { } override suspend fun create(model: Schedule): Long { - return repositoryPost("$prefix/api/schedules", Message.json.stringify(Schedule.serializer(), model)) + return repositoryPost("$prefix/api/schedules", Serialization.stringify(Schedule.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: Schedule) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/schedule/${model.id}", Message.json.stringify(Schedule.serializer(), model)) + repositoryPost("$prefix/api/schedule/${model.id}", Serialization.stringify(Schedule.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/TrackRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/TrackRepository.kt index cf5dcae..754cdef 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/TrackRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/TrackRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.Track import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object TrackRepository : Repository { } override suspend fun create(model: Track): Long { - return repositoryPost("$prefix/api/tracks", Message.json.stringify(Track.serializer(), model)) + return repositoryPost("$prefix/api/tracks", Serialization.stringify(Track.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: Track) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/track/${model.id}", Message.json.stringify(Track.serializer(), model)) + repositoryPost("$prefix/api/track/${model.id}", Serialization.stringify(Track.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/UserRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/UserRepository.kt index b5a42db..90c0438 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/UserRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/UserRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.User import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object UserRepository : Repository { } override suspend fun create(model: User): Long { - return repositoryPost("$prefix/api/users", Message.json.stringify(User.serializer(), model)) + return repositoryPost("$prefix/api/users", Serialization.stringify(User.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: User) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/user/${model.id}", Message.json.stringify(User.serializer(), model)) + repositoryPost("$prefix/api/user/${model.id}", Serialization.stringify(User.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/kotlin/de/kif/frontend/repository/WorkGroupRepository.kt b/src/jsMain/kotlin/de/kif/frontend/repository/WorkGroupRepository.kt index 18c240c..6714c71 100644 --- a/src/jsMain/kotlin/de/kif/frontend/repository/WorkGroupRepository.kt +++ b/src/jsMain/kotlin/de/kif/frontend/repository/WorkGroupRepository.kt @@ -3,6 +3,7 @@ package de.kif.frontend.repository import de.kif.common.Message import de.kif.common.Repository import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.WorkGroup import de.kif.frontend.MessageHandler import de.westermann.kobserve.event.EventHandler @@ -25,13 +26,13 @@ object WorkGroupRepository : Repository { } override suspend fun create(model: WorkGroup): Long { - return repositoryPost("$prefix/api/workgroups", Message.json.stringify(WorkGroup.serializer(), model)) + return repositoryPost("$prefix/api/workgroups", Serialization.stringify(WorkGroup.serializer(), model)) ?: throw IllegalStateException("Cannot create model!") } override suspend fun update(model: WorkGroup) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") - repositoryPost("$prefix/api/workgroup/${model.id}", Message.json.stringify(WorkGroup.serializer(), model)) + repositoryPost("$prefix/api/workgroup/${model.id}", Serialization.stringify(WorkGroup.serializer(), model)) } override suspend fun delete(id: Long) { diff --git a/src/jsMain/resources/style/components/_board.scss b/src/jsMain/resources/style/components/_board.scss index 4abf210..f037179 100644 --- a/src/jsMain/resources/style/components/_board.scss +++ b/src/jsMain/resources/style/components/_board.scss @@ -58,7 +58,7 @@ width: 100%; height: 100%; border-bottom: solid 1px var(--table-border-color); - box-shadow: 0 0 4px black; + box-shadow: 0 0 4px var(--shadow-color); top: -1rem; padding-top: 1.2rem; } diff --git a/src/jsMain/resources/style/style.scss b/src/jsMain/resources/style/style.scss index a55129c..b080704 100644 --- a/src/jsMain/resources/style/style.scss +++ b/src/jsMain/resources/style/style.scss @@ -160,3 +160,25 @@ a { } } } + +.offline-banner { + position: fixed; + bottom: 3rem; + left: 0; + right: 0; + height: 3rem; + line-height: 3rem; + text-align: center; + font-size: 1.2rem; + background-color: var(--primary-color); + color: var(--primary-text-color); + z-index: 12; + box-shadow: 0 1px 4px var(--shadow-color); + display: none; +} + +body.offline { + .offline-banner { + display: block; + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt index a4ef155..46a1330 100644 --- a/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt +++ b/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt @@ -3,10 +3,7 @@ package de.kif.backend.repository import de.kif.backend.database.DbWorkGroup import de.kif.backend.database.dbQuery import de.kif.backend.util.PushService -import de.kif.common.Message -import de.kif.common.MessageType -import de.kif.common.Repository -import de.kif.common.RepositoryType +import de.kif.common.* import de.kif.common.model.Constraint import de.kif.common.model.WorkGroup import de.westermann.kobserve.event.EventHandler @@ -36,8 +33,8 @@ object WorkGroupRepository : Repository { val accessible = row[DbWorkGroup.accessible] val length = row[DbWorkGroup.length] val language = row[DbWorkGroup.language] - val leader = Message.json.parse(String.serializer().list, row[DbWorkGroup.leader]) - val constraints = Message.json.parse(Constraint.serializer().list, row[DbWorkGroup.constraints]) + val leader = Serialization.parse(String.serializer().list, row[DbWorkGroup.leader]) + val constraints = Serialization.parse(Constraint.serializer().list, row[DbWorkGroup.constraints]) val createdAt = row[DbWorkGroup.createdAt] val updatedAt = row[DbWorkGroup.updatedAt] @@ -92,8 +89,8 @@ object WorkGroupRepository : Repository { it[accessible] = model.accessible it[length] = model.length it[language] = model.language - it[leader] = Message.json.stringify(String.serializer().list, model.leader) - it[constraints] = Message.json.stringify(Constraint.serializer().list, model.constraints) + it[leader] = Serialization.stringify(String.serializer().list, model.leader) + it[constraints] = Serialization.stringify(Constraint.serializer().list, model.constraints) it[createdAt] = now it[updatedAt] = now @@ -124,8 +121,8 @@ object WorkGroupRepository : Repository { it[accessible] = model.accessible it[length] = model.length it[language] = model.language - it[leader] = Message.json.stringify(String.serializer().list, model.leader) - it[constraints] = Message.json.stringify(Constraint.serializer().list, model.constraints) + it[leader] = Serialization.stringify(String.serializer().list, model.leader) + it[constraints] = Serialization.stringify(Constraint.serializer().list, model.constraints) it[updatedAt] = now } diff --git a/src/jvmMain/kotlin/de/kif/backend/util/Backup.kt b/src/jvmMain/kotlin/de/kif/backend/util/Backup.kt index b9f758f..63b7843 100644 --- a/src/jvmMain/kotlin/de/kif/backend/util/Backup.kt +++ b/src/jvmMain/kotlin/de/kif/backend/util/Backup.kt @@ -4,6 +4,7 @@ import de.kif.backend.database.Connection import de.kif.backend.repository.* import de.kif.common.Message import de.kif.common.RepositoryType +import de.kif.common.Serialization import de.kif.common.model.* import kotlinx.serialization.Serializable @@ -42,12 +43,12 @@ data class Backup( } } - return Message.json.stringify(serializer(), backup) + return Serialization.stringify(serializer(), backup) } @Suppress("UNUSED_VARIABLE") suspend fun import(data: String) { - val backup = Message.json.parse(serializer(), data) + val backup = Serialization.parse(serializer(), data) backup.users.forEach { UserRepository.create(it); println("Import user ${it.username}") } backup.posts.forEach { PostRepository.create(it); println("Import post") } diff --git a/src/jvmMain/kotlin/de/kif/backend/util/PushService.kt b/src/jvmMain/kotlin/de/kif/backend/util/PushService.kt index 12f07e3..ed3f172 100644 --- a/src/jvmMain/kotlin/de/kif/backend/util/PushService.kt +++ b/src/jvmMain/kotlin/de/kif/backend/util/PushService.kt @@ -1,48 +1,60 @@ package de.kif.backend.util -import de.kif.backend.prefix import de.kif.backend.repository.* -import de.kif.common.* +import de.kif.backend.route.api.error +import de.kif.backend.route.api.success +import de.kif.common.Message +import de.kif.common.MessageBox +import de.kif.common.MessageType import de.kif.common.RepositoryType -import io.ktor.http.cio.websocket.Frame -import io.ktor.http.cio.websocket.readText +import io.ktor.application.call +import io.ktor.http.HttpStatusCode import io.ktor.routing.Route -import io.ktor.websocket.WebSocketServerSession -import io.ktor.websocket.webSocket -import kotlinx.coroutines.channels.ClosedReceiveChannelException +import io.ktor.routing.get object PushService { - var clients: List = emptyList() + var leastValidTimestamp = System.currentTimeMillis() - suspend fun notify(type: MessageType, repository: RepositoryType, id: Long) { - try { - val data = Message(type, repository, id).stringify() - for (client in clients) { - client.outgoing.send(Frame.Text(data)) - } - } catch (e: Exception) { - e.printStackTrace() - } + /** + * Save the message with the current timestamp + */ + fun notify(type: MessageType, repository: RepositoryType, id: Long) { + val timestamp = System.currentTimeMillis() + val message = Message(type, repository, id) + } + + /** + * Get all messages created after the given timestamp. + * The return Box has the current timestamp. If the leastValidTimestamp is less then the current timestamp set the + * valid flag to false and return an empty message list. + */ + fun getMessages(timestamp: Long?): MessageBox { + return MessageBox( + System.currentTimeMillis(), + true, + emptyList() + ) + } + + /** + * Delete all messages before the given timestamp + */ + fun gc(timestamp: Long) { + leastValidTimestamp = timestamp } } fun Route.pushService() { - webSocket("/websocket") { - PushService.clients += this - + get("/api/updates") { try { - while (true) { - val text = (incoming.receive() as Frame.Text).readText() - println("onMessage($text)") - outgoing.send(Frame.Text(text)) - } - } catch (_: ClosedReceiveChannelException) { - PushService.clients -= this - } catch (e: Throwable) { - println("onFailure ${closeReason.await()}") - e.printStackTrace() - PushService.clients -= this + val timestamp = call.request.queryParameters["timestamp"]?.toLongOrNull() + + val messageBox = PushService.getMessages(timestamp) + + call.success(messageBox) + } catch (_: Exception) { + call.error(HttpStatusCode.InternalServerError) } } diff --git a/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt b/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt index f96a52e..5bc149d 100644 --- a/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt +++ b/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt @@ -73,6 +73,8 @@ class MainTemplate( } } body { + attributes["data-timestamp"] = currentTimeMillis().toString() + if (!noMenu) { insert(MenuTemplate(url, user)) {} } @@ -84,6 +86,10 @@ class MainTemplate( } } + div("offline-banner") { + span { +"Die Verbindung zum Server ist unterbrochen" } + } + if (!noMenu) { div("footer") { div("container") {