diff --git a/build.gradle b/build.gradle index 58e9f96..fda923c 100644 --- a/build.gradle +++ b/build.gradle @@ -11,8 +11,8 @@ buildscript { } plugins { - id 'kotlin-multiplatform' version '1.3.20' - id 'kotlinx-serialization' version '1.3.20' + id 'kotlin-multiplatform' version '1.3.31' + id 'kotlinx-serialization' version '1.3.31' id "org.kravemir.gradle.sass" version "1.2.2" id "com.github.johnrengelman.shadow" version "4.0.4" } @@ -24,10 +24,11 @@ repositories { jcenter() maven { url "http://dl.bintray.com/kotlin/ktor" } maven { url "https://kotlin.bintray.com/kotlinx" } + maven { url "https://kotlin.bintray.com/kotlin-js-wrappers" } mavenCentral() } -def ktor_version = '1.1.2' -def serialization_version = '0.10.0' +def ktor_version = '1.1.5' +def serialization_version = '0.11.0' kotlin { jvm() { @@ -72,7 +73,8 @@ kotlin { implementation "io.ktor:ktor-server-netty:$ktor_version" implementation "io.ktor:ktor-auth:$ktor_version" implementation "io.ktor:ktor-locations:$ktor_version" - //implementation "io.ktor:ktor-websockets:$ktor_version" + implementation "io.ktor:ktor-websockets:$ktor_version" + implementation 'org.jetbrains:kotlin-css-jvm:1.0.0-pre.70-kotlin-1.3.21' implementation "io.ktor:ktor-html-builder:$ktor_version" @@ -98,7 +100,7 @@ kotlin { implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-js:$serialization_version" - implementation "de.westermann:KObserve-js:0.8.0" + implementation "de.westermann:KObserve-js:0.9.1" } } jsTest { diff --git a/src/commonMain/kotlin/kif/common/model/Color.kt b/src/commonMain/kotlin/kif/common/model/Color.kt new file mode 100644 index 0000000..cc073e4 --- /dev/null +++ b/src/commonMain/kotlin/kif/common/model/Color.kt @@ -0,0 +1,96 @@ +package kif.common.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +data class Color( + val red: Int, + val green: Int, + val blue: Int, + val alpha: Double = 1.0 +) { + override fun toString(): String { + return if (alpha >= 1.0) { + val r = red.toString(16).padStart(2, '0') + val g = green.toString(16).padStart(2, '0') + val b = blue.toString(16).padStart(2, '0') + "#$r$g$b" + } else { + "rgba($red, $green, $blue, $alpha)" + } + } + + @Transient + val redDouble + get() = red / 255.0 + + @Transient + val greenDouble + get() = green / 255.0 + + @Transient + val blueDouble + get() = blue / 255.0 + + @Transient + val luminance: Double + get() = 0.2126 * redDouble + 0.7152 * greenDouble + 0.0722 * blueDouble + + @Transient + val textColor: Color + get() = if (luminance < 0.7) WHITE else BLACK + + companion object { + fun parse(color: String): Color { + val r: Int + val g: Int + val b: Int + val a: Double + if (color.startsWith("#")) { + if (color.length == 3) { + r = color.substring(1, 2).toInt(16) + g = color.substring(2, 3).toInt(16) + b = color.substring(3, 4).toInt(16) + } else { + r = color.substring(1, 3).toInt(16) + g = color.substring(3, 5).toInt(16) + b = color.substring(5, 7).toInt(16) + } + a = 1.0 + } else { + val split = color.substringAfter("(").substringBefore(")").split(",") + r = split[0].toInt() + g = split[1].toInt() + b = split[2].toInt() + a = split.getOrNull(3)?.toDouble() ?: 1.0 + } + return Color(r, g, b, a) + } + + val WHITE = Color(255, 255, 255) + val BLACK = Color(51, 51, 51) + + val default = listOf( + "red" to parse("#F44336"), + "pink" to parse("#E91E63"), + "purple" to parse("#9C27B0"), + "deep-purple" to parse("#673AB7"), + "indigo" to parse("#3F51B5"), + "blue" to parse("#1E88E5"), + "light blue" to parse("#03A9F4"), + "cyan" to parse("#00BCD4"), + "teal" to parse("#009688"), + "green" to parse("#43A047"), + "light-green" to parse("#7CB342"), + "lime" to parse("#C0CA33"), + "yellow" to parse("#FDD835"), + "amber" to parse("#FFB300"), + "orange" to parse("#FB8C00"), + "deep-orange" to parse("#F4511E"), + "brown" to parse("#795548"), + "gray" to parse("#9E9E9E"), + "blue gray" to parse("#607D8B") + ) + } +} diff --git a/src/commonMain/kotlin/kif/common/model/Constants.kt b/src/commonMain/kotlin/kif/common/model/Constants.kt new file mode 100644 index 0000000..2c87c39 --- /dev/null +++ b/src/commonMain/kotlin/kif/common/model/Constants.kt @@ -0,0 +1,3 @@ +package kif.common.model + +const val CALENDAR_GRID_WIDTH = 15 \ No newline at end of file diff --git a/src/commonMain/kotlin/kif/common/model/Message.kt b/src/commonMain/kotlin/kif/common/model/Message.kt new file mode 100644 index 0000000..1e87a84 --- /dev/null +++ b/src/commonMain/kotlin/kif/common/model/Message.kt @@ -0,0 +1,58 @@ +package kif.common.model + +import kotlinx.serialization.Polymorphic +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule + +@Serializable +data class Message( + @Polymorphic + val message: MessageType +) { + + fun stringify(): String = Companion.stringify(this) + + companion object { + + private val module = SerializersModule { + polymorphic(MessageType::class) { + MessageCreateCalendarEntry::class with MessageCreateCalendarEntry.serializer() + MessageDeleteCalendarEntry::class with MessageDeleteCalendarEntry.serializer() + } + } + + private val json = Json(context = module) + + fun stringify(message: Message): String { + return json.stringify(serializer(), message) + } + + fun parse(message: String): Message { + return json.parse(serializer(), message) + } + } +} + +abstract class MessageType() + +@Serializable +data class MessageCreateCalendarEntry( + val day: Int, + val time: Int, + val cellTime: Int, + val room: Int, + val workGroupId: Int, + val workGroupName: String, + val workGroupLength: Int, + val workGroupLanguage: String, + val workGroupColor: Color? = null +) : MessageType() + +@Serializable +data class MessageDeleteCalendarEntry( + val day: Int, + val time: Int, + val roomId: Int, + val workGroupId: Int +) : MessageType() diff --git a/src/jsMain/kotlin/de/kif/frontend/main.kt b/src/jsMain/kotlin/de/kif/frontend/main.kt index cb72995..6a99087 100644 --- a/src/jsMain/kotlin/de/kif/frontend/main.kt +++ b/src/jsMain/kotlin/de/kif/frontend/main.kt @@ -1,19 +1,381 @@ package de.kif.frontend -import de.kif.frontend.calendar.Calendar -import de.westermann.kwebview.components.boxView -import de.westermann.kwebview.components.h1 +import de.westermann.kobserve.property.mapBinding +import de.westermann.kwebview.* +import de.westermann.kwebview.components.Body import de.westermann.kwebview.components.init +import kif.common.model.* +import org.w3c.dom.* +import org.w3c.dom.events.EventListener +import org.w3c.dom.events.MouseEvent +import kotlin.browser.document +import kotlin.browser.window +import kotlin.collections.Iterator +import kotlin.collections.List +import kotlin.collections.emptyList +import kotlin.collections.filter +import kotlin.collections.find +import kotlin.collections.forEach +import kotlin.collections.listOf +import kotlin.collections.minus +import kotlin.collections.plus +import kotlin.dom.appendText -fun main() = init { - clear() - h1("Test") - boxView { - style { - width = "600px" - height = "400px" - margin = "10px" +class CalendarTools(entry: CalendarEntry, view: HTMLElement) : View(view) { + + init { + var linkM10: HTMLAnchorElement? = null + var linkM5: HTMLAnchorElement? = null + var linkReset: HTMLAnchorElement? = null + var linkP5: HTMLAnchorElement? = null + var linkP10: HTMLAnchorElement? = null + var linkDel: HTMLAnchorElement? = null + + for (element in html.children) { + when { + element.classList.contains("calendar-tools-m10") -> linkM10 = element as? HTMLAnchorElement + element.classList.contains("calendar-tools-m5") -> linkM5 = element as? HTMLAnchorElement + element.classList.contains("calendar-tools-reset") -> linkReset = element as? HTMLAnchorElement + element.classList.contains("calendar-tools-p5") -> linkP5 = element as? HTMLAnchorElement + element.classList.contains("calendar-tools-p10") -> linkP10 = element as? HTMLAnchorElement + element.classList.contains("calendar-tools-del") -> linkDel = element as? HTMLAnchorElement + } } - +Calendar() + + linkM10 = linkM10 ?: run { + val link = createHtmlView() + link.classList.add("calendar-tools-m10") + link.textContent = "-10" + html.appendChild(link) + link + } + linkM10.removeAttribute("href") + linkM10.addEventListener("click", EventListener { + classList += "pending" + get("/calendar/${entry.day}/${entry.room}/${entry.time}/-1") { + get("/calendar/${entry.day}/${entry.room}/${entry.timeId - 10}/${entry.workGroup}") { + println("success") + } + } + }) + + linkM5 = linkM5 ?: run { + val link = createHtmlView() + link.classList.add("calendar-tools-m5") + link.textContent = "-5" + html.appendChild(link) + link + } + linkM5.removeAttribute("href") + linkM5.addEventListener("click", EventListener { + classList += "pending" + get("/calendar/${entry.day}/${entry.room}/${entry.time}/-1") { + get("/calendar/${entry.day}/${entry.room}/${entry.timeId - 5}/${entry.workGroup}") { + println("success") + } + } + }) + + linkReset = linkReset ?: run { + val link = createHtmlView() + link.classList.add("calendar-tools-reset") + link.textContent = "reset" + html.appendChild(link) + link + } + linkReset.removeAttribute("href") + linkReset.addEventListener("click", EventListener { + classList += "pending" + get("/calendar/${entry.day}/${entry.room}/${entry.time}/-1") { + get("/calendar/${entry.day}/${entry.room}/${entry.cellTime}/${entry.workGroup}") { + println("success") + } + } + }) + + linkP5 = linkP5 ?: run { + val link = createHtmlView() + link.classList.add("calendar-tools-p5") + link.textContent = "+5" + html.appendChild(link) + link + } + linkP5.removeAttribute("href") + linkP5.addEventListener("click", EventListener { + classList += "pending" + get("/calendar/${entry.day}/${entry.room}/${entry.time}/-1") { + get("/calendar/${entry.day}/${entry.room}/${entry.timeId + 5}/${entry.workGroup}") { + println("success") + } + } + }) + + linkP10 = linkP10 ?: run { + val link = createHtmlView() + link.classList.add("calendar-tools-p10") + link.textContent = "+10" + html.appendChild(link) + link + } + linkP10.removeAttribute("href") + linkP10.addEventListener("click", EventListener { + classList += "pending" + get("/calendar/${entry.day}/${entry.room}/${entry.time}/-1") { + get("/calendar/${entry.day}/${entry.room}/${entry.timeId + 10}/${entry.workGroup}") { + println("success") + } + } + }) + + linkDel = linkDel ?: run { + val link = createHtmlView() + link.classList.add("calendar-tools-del") + link.textContent = "del" + html.appendChild(link) + link + } + linkDel.removeAttribute("href") + linkDel.addEventListener("click", EventListener { + classList += "pending" + get("/calendar/${entry.day}/${entry.room}/${entry.time}/-1") { + println("success") + } + }) } } + +class CalendarEntry(view: HTMLElement) : View(view) { + + private lateinit var mouseDelta: Point + private var newCell: CalendarCell? = null + + + var day by dataset.property("day") + val dayId by dataset.property("day").mapBinding { it?.toIntOrNull() ?: 0 } + + var time by dataset.property("time") + val timeId by dataset.property("time").mapBinding { it?.toIntOrNull() ?: 0 } + + var room by dataset.property("room") + val roomId by dataset.property("room").mapBinding { it?.toIntOrNull() ?: 0 } + + var cellTime by dataset.property("cellTime") + var language by dataset.property("language") + + var workGroup by dataset.property("workgroup") + val workGroupId by dataset.property("workgroup").mapBinding { it?.toIntOrNull() ?: 0 } + private fun onMove(event: MouseEvent) { + val position = event.toPoint() - mouseDelta + + newCell?.classList?.remove("drag") + + val cell = calendarCells.find { + position in it.dimension + } + + if (cell != null) { + cell.classList.add("drag") + cell += this + newCell = cell + } + + event.preventDefault() + event.stopPropagation() + } + + private fun onFinishMove(event: MouseEvent) { + classList -= "drag" + + newCell?.let { cell -> + cell.classList -= "drag" + + val newTime = cell.time + val newRoom = cell.room + val day = + (document.getElementsByClassName("calendar")[0] as? HTMLElement)?.dataset?.get("day")?.toIntOrNull() + ?: 0 + + classList += "pending" + get("/calendar/$day/$room/$time/-1") { + get("/calendar/$day/$newRoom/$newTime/$workGroup") { + println("success") + } + } + } + newCell = null + + for (it in listeners) { + it.detach() + } + listeners = emptyList() + + event.preventDefault() + event.stopPropagation() + } + + private var listeners: List> = emptyList() + + init { + onMouseDown { event -> + if (event.target != html || "pending" in classList) { + event.stopPropagation() + return@onMouseDown + } + + classList += "drag" + + mouseDelta = event.toPoint() - point + + listeners = listOf( + Body.onMouseMove.reference(this::onMove), + Body.onMouseUp.reference(this::onFinishMove), + Body.onMouseLeave.reference(this::onFinishMove) + ) + + event.preventDefault() + event.stopPropagation() + } + + var calendarTools: CalendarTools? = null + for (item in html.children) { + if (item.classList.contains("calendar-tools")) { + calendarTools = CalendarTools(this, item) + break + } + } + if (calendarTools == null) { + calendarTools = CalendarTools(this, createHtmlView()) + html.appendChild(calendarTools.html) + } + } + + companion object { + fun create( + day: Int, + time: Int, + cellTime: Int, + room: Int, + workGroupId: Int, + workGroupName: String, + workGroupLength: Int, + workGroupLanguage: String, + workGroupColor: Color? + ): CalendarEntry { + val entry = CalendarEntry(createHtmlView()) + + entry.day = day.toString() + entry.time = time.toString() + entry.cellTime = cellTime.toString() + entry.room = room.toString() + entry.workGroup = workGroupId.toString() + entry.language = workGroupLanguage + if (workGroupColor != null) { + + entry.style { + val size = workGroupLength / CALENDAR_GRID_WIDTH.toDouble() + val pos = (time % CALENDAR_GRID_WIDTH) / CALENDAR_GRID_WIDTH.toDouble() + + val pct = "${pos * 100}%" + val sz = "${size * 100}%" + + left = pct + top = "calc($pct + 0.1rem)" + + width = sz + height = "calc($sz - 0.2rem)" + + backgroundColor = workGroupColor.toString() + color = workGroupColor.textColor.toString() + } + } + entry.html.appendText(workGroupName) + + return entry + } + } +} + +class CalendarCell(view: HTMLElement) : ViewCollection(view) { + var day by dataset.property("day") + val dayId by dataset.property("day").mapBinding { it?.toIntOrNull() ?: 0 } + + var time by dataset.property("time") + val timeId by dataset.property("time").mapBinding { it?.toIntOrNull() ?: 0 } + + var room by dataset.property("room") + val roomId by dataset.property("room").mapBinding { it?.toIntOrNull() ?: 0 } +} + +var calendarEntries: List = emptyList() +var calendarCells: List = emptyList() + +fun main() = init { + + val ws = WebSocket("ws://${window.location.host}/".also { println(it) }) + + ws.onmessage = { + val messageWrapper = Message.parse(it.data?.toString() ?: "") + val message = messageWrapper.message + + println(message) + + when (message) { + is MessageCreateCalendarEntry -> { + val entry = CalendarEntry.create( + message.day, + message.time, + message.cellTime, + message.room, + message.workGroupId, + message.workGroupName, + message.workGroupLength, + message.workGroupLanguage, + message.workGroupColor + ) + for (cell in calendarCells) { + if (cell.dayId == message.day && cell.timeId == message.cellTime && cell.roomId == message.room) { + cell.html.appendChild(entry.html) + calendarEntries += entry + break + } + } + } + is MessageDeleteCalendarEntry -> { + val remove = calendarEntries.filter { entry -> + entry.dayId == message.day && + entry.timeId == message.time && + entry.roomId == message.roomId && + entry.workGroupId == message.workGroupId + } + calendarEntries -= remove + remove.forEach { + it.html.remove() + } + } + else -> { + } + } + } + + ws.onopen = { + console.log("yes!") + } + + calendarEntries = document.getElementsByClassName("calendar-entry") + .iterator().asSequence().map(::CalendarEntry).toList() + + calendarCells = document.getElementsByClassName("calendar-cell") + .iterator().asSequence().filter { it.dataset["time"] != null }.map(::CalendarCell).toList() +} + +operator fun HTMLCollection.iterator() = object : Iterator { + private var index = 0 + override fun hasNext(): Boolean { + return index < this@iterator.length + } + + override fun next(): HTMLElement { + return this@iterator.get(index++) as HTMLElement + } + +} \ No newline at end of file diff --git a/src/jsMain/kotlin/de/westermann/kwebview/ClassDelegate.kt b/src/jsMain/kotlin/de/westermann/kwebview/ClassDelegate.kt index e61e796..61e2b99 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/ClassDelegate.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/ClassDelegate.kt @@ -1,7 +1,7 @@ package de.westermann.kwebview import de.westermann.kobserve.Property -import de.westermann.kobserve.basic.property +import de.westermann.kobserve.property.property import kotlin.reflect.KProperty /** diff --git a/src/jsMain/kotlin/de/westermann/kwebview/ClassList.kt b/src/jsMain/kotlin/de/westermann/kwebview/ClassList.kt index 4921668..27d0491 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/ClassList.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/ClassList.kt @@ -1,6 +1,6 @@ package de.westermann.kwebview -import de.westermann.kobserve.ListenerReference +import de.westermann.kobserve.event.EventListener import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty import org.w3c.dom.DOMTokenList @@ -102,7 +102,7 @@ class ClassList( throw IllegalArgumentException("Class is not bound!") } - bound[clazz]?.reference?.remove() + bound[clazz]?.reference?.detach() bound -= clazz } @@ -114,6 +114,6 @@ class ClassList( private data class Bound( val property: ReadOnlyProperty, - val reference: ListenerReference? + val reference: EventListener? ) } \ No newline at end of file diff --git a/src/jsMain/kotlin/de/westermann/kwebview/DataSet.kt b/src/jsMain/kotlin/de/westermann/kwebview/DataSet.kt index 5103d75..cabfbb8 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/DataSet.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/DataSet.kt @@ -1,11 +1,16 @@ package de.westermann.kwebview -import de.westermann.kobserve.ListenerReference import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty +import de.westermann.kobserve.event.EventListener import org.w3c.dom.DOMStringMap import org.w3c.dom.get import org.w3c.dom.set +import kotlin.collections.MutableMap +import kotlin.collections.contains +import kotlin.collections.minusAssign +import kotlin.collections.mutableMapOf +import kotlin.collections.set /** * Represents the css classes of an html element. @@ -13,7 +18,7 @@ import org.w3c.dom.set * @author lars */ class DataSet( - private val map: DOMStringMap + private val map: DOMStringMap ) { private val bound: MutableMap = mutableMapOf() @@ -49,11 +54,11 @@ class DataSet( * Set css class present. */ operator fun set(key: String, value: String?) = - if (value == null) { - this -= key - } else { - this += key to value - } + if (value == null) { + this -= key + } else { + this += key to value + } fun bind(key: String, property: ReadOnlyProperty) { if (key in bound) { @@ -71,22 +76,34 @@ class DataSet( bound[key] = Bound(key, property, null) } + fun property(key: String): Property { + if (key in bound) { + return bound[key]?.propertyNullable as? Property ?: throw IllegalArgumentException("Class is already bound!") + } + + val property = de.westermann.kobserve.property.property(get(key)) + + bound[key] = Bound(key, property, null) + + return property + } + fun unbind(key: String) { if (key !in bound) { throw IllegalArgumentException("Class is not bound!") } - bound[key]?.reference?.remove() + bound[key]?.reference?.detach() bound -= key } private inner class Bound( - val key: String, - val propertyNullable: ReadOnlyProperty?, - val property: ReadOnlyProperty? + val key: String, + val propertyNullable: ReadOnlyProperty?, + val property: ReadOnlyProperty? ) { - var reference: ListenerReference? = null + var reference: EventListener? = null fun set(value: String?) { if (propertyNullable != null && propertyNullable is Property) { diff --git a/src/jsMain/kotlin/de/westermann/kwebview/View.kt b/src/jsMain/kotlin/de/westermann/kwebview/View.kt index 7090b93..e556042 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/View.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/View.kt @@ -1,6 +1,7 @@ package de.westermann.kwebview -import de.westermann.kobserve.EventHandler +import de.westermann.kobserve.event.EventHandler +import org.w3c.dom.DragEvent import org.w3c.dom.HTMLElement import org.w3c.dom.css.CSSStyleDeclaration import org.w3c.dom.events.FocusEvent @@ -63,6 +64,9 @@ abstract class View(view: HTMLElement = createHtmlView()) { val dimension: Dimension get() = html.getBoundingClientRect().toDimension() + val point: Point + get() = dimension.position + var title by AttributeDelegate() val style = view.style @@ -101,6 +105,15 @@ abstract class View(view: HTMLElement = createHtmlView()) { val onFocus = EventHandler() val onBlur = EventHandler() + + val onDragStart = EventHandler() + val onDrag = EventHandler() + val onDragEnter = EventHandler() + val onDragLeave = EventHandler() + val onDragOver = EventHandler() + val onDrop = EventHandler() + val onDragEnd = EventHandler() + init { onClick.bind(view, "click") onDblClick.bind(view, "dblclick") @@ -120,5 +133,17 @@ abstract class View(view: HTMLElement = createHtmlView()) { onFocus.bind(view, "focus") onBlur.bind(view, "blur") + + onDragStart.bind(view, "dragstart") + onDrag.bind(view, "drag") + onDragEnter.bind(view, "dragenter") + onDragLeave.bind(view, "dragleave") + onDragOver.bind(view, "dragover") + onDrop.bind(view, "drop") + onDragEnd.bind(view, "dragend") + } + + companion object { + fun wrap(htmlElement: HTMLElement) = object : View(htmlElement) {} } } diff --git a/src/jsMain/kotlin/de/westermann/kwebview/ViewCollection.kt b/src/jsMain/kotlin/de/westermann/kwebview/ViewCollection.kt index 44e4d43..c9af304 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/ViewCollection.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/ViewCollection.kt @@ -66,4 +66,8 @@ abstract class ViewCollection(view: HTMLElement = createHtmlView()) : operator fun V.unaryPlus() { append(this) } + + companion object { + fun wrap(htmlElement: HTMLElement) = object : ViewCollection(htmlElement) {} + } } diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Button.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Button.kt index 5094ab9..dbed7f5 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/components/Button.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Button.kt @@ -2,7 +2,7 @@ package de.westermann.kwebview.components import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty -import de.westermann.kobserve.basic.property +import de.westermann.kobserve.property.property import de.westermann.kwebview.KWebViewDsl import de.westermann.kwebview.View import de.westermann.kwebview.ViewCollection diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Checkbox.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Checkbox.kt index a4aaf4f..7b9aa41 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/components/Checkbox.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Checkbox.kt @@ -3,7 +3,7 @@ package de.westermann.kwebview.components import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty import de.westermann.kobserve.ValidationProperty -import de.westermann.kobserve.basic.property +import de.westermann.kobserve.property.property import de.westermann.kwebview.* import org.w3c.dom.events.Event import org.w3c.dom.events.EventListener diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Heading.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Heading.kt index 7808b0f..46a1af2 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/components/Heading.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Heading.kt @@ -2,7 +2,7 @@ package de.westermann.kwebview.components import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty -import de.westermann.kobserve.basic.property +import de.westermann.kobserve.property.property import de.westermann.kwebview.KWebViewDsl import de.westermann.kwebview.View import de.westermann.kwebview.ViewCollection diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/ImageView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/ImageView.kt index c1c1f6b..b2dea0c 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/components/ImageView.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/ImageView.kt @@ -2,7 +2,7 @@ package de.westermann.kwebview.components import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty -import de.westermann.kobserve.basic.property +import de.westermann.kobserve.property.property import de.westermann.kwebview.* import org.w3c.dom.HTMLImageElement diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/InputView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/InputView.kt index b7c5685..ea6b839 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/components/InputView.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/InputView.kt @@ -3,7 +3,7 @@ package de.westermann.kwebview.components import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty import de.westermann.kobserve.ValidationProperty -import de.westermann.kobserve.basic.property +import de.westermann.kobserve.property.property import de.westermann.kobserve.not import de.westermann.kwebview.* import org.w3c.dom.HTMLInputElement diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Label.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Label.kt index c2985ea..f8036e8 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/components/Label.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Label.kt @@ -2,7 +2,7 @@ package de.westermann.kwebview.components import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty -import de.westermann.kobserve.basic.property +import de.westermann.kobserve.property.property import de.westermann.kwebview.* import org.w3c.dom.HTMLLabelElement diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/SelectView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/SelectView.kt index d1fa0f1..5695fea 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/components/SelectView.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/SelectView.kt @@ -2,7 +2,7 @@ package de.westermann.kwebview.components import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty -import de.westermann.kobserve.basic.property +import de.westermann.kobserve.property.property import de.westermann.kwebview.AttributeDelegate import de.westermann.kwebview.KWebViewDsl import de.westermann.kwebview.ViewCollection diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/TextView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/TextView.kt index 9228091..0472cb9 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/components/TextView.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/TextView.kt @@ -2,7 +2,7 @@ package de.westermann.kwebview.components import de.westermann.kobserve.Property import de.westermann.kobserve.ReadOnlyProperty -import de.westermann.kobserve.basic.property +import de.westermann.kobserve.property.property import de.westermann.kwebview.KWebViewDsl import de.westermann.kwebview.View import de.westermann.kwebview.ViewCollection diff --git a/src/jsMain/kotlin/de/westermann/kwebview/extensions.kt b/src/jsMain/kotlin/de/westermann/kwebview/extensions.kt index 598b6bd..fb745f9 100644 --- a/src/jsMain/kotlin/de/westermann/kwebview/extensions.kt +++ b/src/jsMain/kotlin/de/westermann/kwebview/extensions.kt @@ -1,11 +1,13 @@ package de.westermann.kwebview -import de.westermann.kobserve.EventHandler +import de.westermann.kobserve.event.EventHandler import org.w3c.dom.DOMRect import org.w3c.dom.HTMLElement import org.w3c.dom.events.Event import org.w3c.dom.events.EventListener import org.w3c.dom.events.MouseEvent +import org.w3c.xhr.FormData +import org.w3c.xhr.XMLHttpRequest import kotlin.browser.document import kotlin.browser.window @@ -15,9 +17,8 @@ inline fun createHtmlView(tag: String? = null): V { tagName = tag } else { tagName = V::class.js.name.toLowerCase().replace("html([a-z]*)element".toRegex(), "$1") - if (tagName.isBlank()) { - tagName = "div" - } + if (tagName.isBlank()) tagName = "div" + if (tagName == "anchor") tagName = "a" } return document.createElement(tagName) as V } @@ -77,3 +78,66 @@ fun interval(timeout: Int, block: () -> Unit): Int { fun clearInterval(id: Int) { window.clearInterval(id) } + +fun get( + url: String, + data: Map = emptyMap(), + onError: (Int) -> Unit = {}, + onSuccess: (String) -> Unit = {} +) { + val xhttp = XMLHttpRequest() + + xhttp.onreadystatechange = { + if (xhttp.readyState == 4.toShort()) { + if (xhttp.status == 200.toShort() || xhttp.status == 304.toShort()) { + onSuccess(xhttp.responseText) + } else { + onError(xhttp.status.toInt()) + } + } + } + xhttp.open("GET", url, true) + + if (data.isNotEmpty()) { + xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + val formData = FormData() + for ((key, value) in data) { + formData.append(key, value) + } + xhttp.send(formData) + } else { + xhttp.send() + } +} + +fun post( + url: String, + data: Map = emptyMap(), + onError: (Int) -> Unit = {}, + onSuccess: (String) -> Unit = {} +) { + val xhttp = XMLHttpRequest() + + xhttp.onreadystatechange = { + if (xhttp.readyState == 4.toShort()) { + console.log(xhttp.status) + if (xhttp.status == 200.toShort() || xhttp.status == 304.toShort()) { + onSuccess(xhttp.responseText) + } else { + onError(xhttp.status.toInt()) + } + } + } + xhttp.open("POST", url, true) + + if (data.isNotEmpty()) { + xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + val formData = FormData() + for ((key, value) in data) { + formData.append(key, value) + } + xhttp.send(formData) + } else { + xhttp.send() + } +} diff --git a/src/jsMain/resources/style/style.scss b/src/jsMain/resources/style/style.scss index 1483aa4..7ba7fca 100644 --- a/src/jsMain/resources/style/style.scss +++ b/src/jsMain/resources/style/style.scss @@ -102,6 +102,7 @@ a { position: relative; text-transform: uppercase; white-space: nowrap; + z-index: 6; &::after { content: ''; @@ -157,13 +158,24 @@ a { display: none; position: absolute; background-color: $background-secondary-color; - z-index: 1; + z-index: 5; left: 1rem; right: 1rem; + &::before { + content: ''; + top: -8rem; + left: 0; + right: 0; + height: 8rem; + display: block; + position: absolute; + } + a { display: block; line-height: 3rem; + &:after { bottom: 0.2rem; } @@ -192,6 +204,7 @@ a { a { display: inline-block; line-height: unset; + &:after { bottom: 1.8em } @@ -215,7 +228,7 @@ a { .btn-search { position: absolute; top: 0; - height: calc(2.5rem + 2px); + height: 2.5rem; line-height: 2.5rem; right: -3px; border-bottom-left-radius: 0; @@ -228,9 +241,6 @@ a { input:focus ~ .btn-search { border-color: $primary-color; border-width: 2px; - margin-top: 0; - height: calc(2.5rem + 4px); - margin-right: 1px; } } @@ -282,6 +292,7 @@ a { outline: none; padding: 0 1rem; line-height: 2.5rem; + height: 2.5rem; width: 100%; background-color: $background-primary-color; border-radius: 0.2rem; @@ -291,10 +302,14 @@ a { &:focus { border-color: $primary-color; border-width: 2px; - margin: 0; } } +select:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 $text-primary-color; +} + .form-group { padding-bottom: 1rem; display: block; @@ -411,7 +426,7 @@ a { border-color: $error-color; } -button::-moz-focus-inner, input::-moz-focus-inner { +button::-moz-focus-inner, input::-moz-focus-inner, select::-moz-focus-inner { border: 0; } @@ -425,3 +440,358 @@ form { width: 24rem; } } + +.input-group { + display: flex; + + .form-btn { + height: 2.5rem; + line-height: 2.5rem; + margin-top: 1px; + } + + & > * { + margin-right: 0; + + &:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: -1px; + } + + &:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } +} + +.calendar { + width: 100%; + position: relative; + margin-top: 1rem; + + & > div { + width: calc(100% - 6rem); + overflow-x: scroll; + overflow-y: visible; + margin-left: 6rem; + padding-bottom: 1rem; + } +} + +.calendar-tools { + position: absolute; + top: -3rem; + right: 0; + background-color: #fff; + padding: 0.2rem 0.5rem; + border-radius: 0.2rem; + display: none; + z-index: 10; + + border: solid 1px $border-color; + box-shadow: 0 0.1rem 0.2rem rgba($primary-text-color); + + a { + padding: 0.2rem; + } + + &::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -1rem; + height: 1rem; + } +} + +.calendar-entry { + position: absolute; + display: block; + border-radius: 0.2rem; + z-index: 1; + line-height: 2rem; + font-size: 0.8rem; + white-space: nowrap; + text-overflow: ellipsis; + + background-color: $primary-color; + color: $primary-text-color; + + padding: 0 0.5rem; + + &::after { + content: attr(data-language); + bottom: 0; + right: 1rem; + opacity: 0.6; + position: absolute; + text-transform: uppercase; + } + + &:hover .calendar-tools { + display: block; + } + + &.drag { + box-shadow: 0 0.1rem 0.2rem rgba($text-primary-color, 0.8); + z-index: 2; + + .calendar-tools { + display: none; + } + } + + &.pending::before { + content: ''; + display: block; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: $background-primary-color; + opacity: 0.6; + } +} + +.calendar-table-time-to-room { + .calendar-header, .calendar-row { + display: flex; + flex-wrap: nowrap; + width: max-content; + } + + .calendar-header { + line-height: 2rem; + height: 2rem; + + .calendar-cell:not(:first-child) { + font-size: 0.8rem; + width: 6rem; + position: relative; + padding-left: 0.2rem; + + &::before { + content: ''; + height: 100%; + left: 0; + top: 0; + border-left: solid 1px rgba($text-primary-color, 0.1); + position: absolute; + } + } + } + + .calendar-row { + border-top: solid 1px rgba($text-primary-color, 0.1); + line-height: 3rem; + height: 3rem; + + .calendar-cell { + position: relative; + width: 1.5rem; + + &:nth-child(2n + 2)::before { + content: ''; + height: 100%; + left: 0; + top: 0; + border-left: solid 1px rgba($text-primary-color, 0.1); + position: absolute; + } + + &:hover { + background-color: rgba($text-primary-color, 0.06); + } + + .calendar-entry { + top: 0.5rem !important; + bottom: 0.5rem; + width: 0; + left: 0; + height: auto !important; + } + } + } + + .calendar-cell:first-child { + position: absolute; + width: 6rem; + left: 0; + text-align: center; + border-right: solid 1px rgba($text-primary-color, 0.1); + } + + .calendar-link { + display: block; + width: 100%; + height: 100%; + } +} + +.calendar-table-room-to-time { + .calendar-header, .calendar-row { + display: flex; + flex-wrap: nowrap; + width: max-content; + } + + .calendar-header { + line-height: 2rem; + height: 2rem; + + .calendar-cell:not(:first-child) { + width: 12rem; + position: relative; + padding-left: 0.2rem; + + &::before { + content: ''; + height: 100%; + left: 0; + top: 0; + border-left: solid 1px rgba($text-primary-color, 0.1); + position: absolute; + } + } + } + + .calendar-row { + line-height: 2rem; + height: 1.3rem; + + &:nth-child(4n + 2)::before { + content: ''; + position: absolute; + width: calc(100% - 6rem); + border-top: solid 1px rgba($text-primary-color, 0.1); + } + + .calendar-cell { + position: relative; + width: 12rem; + + &::before { + content: ''; + height: 100%; + left: 0; + top: 0; + border-left: solid 1px rgba($text-primary-color, 0.1); + position: absolute; + } + + &:hover { + background-color: rgba($text-primary-color, 0.06); + } + + .calendar-entry { + top: 0.1rem; + bottom: 0; + width: auto !important; + left: 0.1rem !important; + right: 0.1rem; + } + } + + .calendar-cell:first-child:not(:empty)::before { + width: 100%; + top: 0; + border-left: none; + border-top: solid 1px rgba($text-primary-color, 0.1); + } + } + + .calendar-cell:first-child { + position: absolute; + width: 6rem; + left: 0; + text-align: center; + } + + .calendar-link { + display: block; + width: 100%; + height: 100%; + } +} + +.color-picker { + .color-picker-entry { + position: relative; + width: 4rem; + height: 4rem; + float: left; + + input { + visibility: hidden; + } + + label { + position: absolute; + top: 0.5rem; + left: 0.5rem; + width: 3rem; + height: 3rem; + border-radius: 1.5rem; + z-index: 1; + } + + input:checked ~ label::before { + content: ''; + position: absolute; + background-color: transparent; + top: 1rem; + left: 0.8rem; + width: 1.1rem; + height: 0.5rem; + transform: rotate(-45deg); + border-left: solid 0.3rem $background-primary-color; + border-bottom: solid 0.3rem $background-primary-color; + } + } + + .color-picker-custom { + position: relative; + clear: both; + height: 4rem; + + & > input { + visibility: hidden; + } + + label { + position: absolute; + top: 0.5rem; + left: 0.5rem; + width: 3rem; + height: 3rem; + border-radius: 1.5rem; + z-index: 1; + border: solid 0.1rem $text-primary-color; + } + + input:checked ~ label::before { + content: ''; + position: absolute; + background-color: transparent; + top: 1rem; + left: 0.8rem; + width: 1.1rem; + height: 0.5rem; + transform: rotate(-45deg); + border-left: solid 0.3rem $text-primary-color; + border-bottom: solid 0.3rem $text-primary-color; + } + + label input { + position: absolute; + left: 4rem; + top: 0.2rem; + height: 2rem; + width: 4rem; + } + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/de/kif/backend/Application.kt b/src/jvmMain/kotlin/de/kif/backend/Application.kt index 75caf31..7eb747e 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Application.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Application.kt @@ -20,7 +20,9 @@ import io.ktor.response.respondRedirect import io.ktor.routing.routing import io.ktor.sessions.* import io.ktor.util.hex +import io.ktor.websocket.WebSockets import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json data class PortalSession(val id: Int, val name: String) { suspend fun getUser(call: ApplicationCall): User { @@ -37,8 +39,39 @@ data class PortalSession(val id: Int, val name: String) { @Location("/") class LocationDashboard() -@Location("/calendar") -class LocationCalendar() +@Location("/calendar/{day}") +data class LocationCalendar(val day: Int) { + @Location("/{room}/{time}") + data class LocationCalendarEdit( + val calendar: LocationCalendar, + val room: Int, + val time: Int, + val search: String = "" + ) { + val day: Int get() = calendar.day + } + + @Location("/{room}/{time}/{workGroup}") + data class LocationCalendarSet( + val calendar: LocationCalendar, + val room: Int, + val time: Int, + val workGroup: Int, + val next: String? = null + ) { + val day: Int get() = calendar.day + } + + @Location("/time-to-room") + data class LocationCalendarTimeToRoom(val calendar: LocationCalendar){ + val day: Int get() = calendar.day + } + + @Location("/room-to-time") + data class LocationCalendarRoomToTime(val calendar: LocationCalendar){ + val day: Int get() = calendar.day + } +} @Location("/login") data class LocationLogin(val username: String = "", val password: String = "", val next: String = "/") @@ -73,6 +106,18 @@ data class LocationWorkGroup(val search: String = "") { data class Delete(val id: Int) } +@Location("/track") +data class LocationTrack(val search: String = "") { + @Location("/{id}") + data class Edit(val id: Int) + + @Location("/new") + class New() + + @Location("/{id}/delete") + data class Delete(val id: Int) +} + @Location("/room") data class LocationRoom(val search: String = "") { @Location("/{id}") @@ -106,6 +151,7 @@ fun Application.main() { install(Compression) install(DataConversion) install(Locations) + install(WebSockets) install(Authentication) { form { @@ -145,9 +191,12 @@ fun Application.main() { account() workGroup() + track() room() person() user() + + pushService() } } } diff --git a/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt b/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt index c4ddb0c..e2270b4 100644 --- a/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt +++ b/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt @@ -19,7 +19,7 @@ object Connection { DbPerson, DbPersonConstraint, DbTrack, DbWorkGroup, DbWorkGroupConstraint, DbLeader, DbWorkGroupOrder, - DbRoom, DbTimeSlot, DbSchedule, + DbRoom, DbSchedule, DbUser, DbUserPermission ) } diff --git a/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt b/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt index 9c07e59..181ca5a 100644 --- a/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt +++ b/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt @@ -25,6 +25,7 @@ object DbPersonConstraint : Table() { object DbTrack : Table() { val id = integer("id").autoIncrement().primaryKey() val name = varchar("name", 64) + val color = varchar("color", 32) } object DbWorkGroup : Table() { @@ -35,6 +36,7 @@ object DbWorkGroup : Table() { val trackId = integer("track_id").nullable() val projector = bool("projector") val resolution = bool("resolution") + val language = enumeration("language", Language::class) val length = integer("length") @@ -70,18 +72,11 @@ object DbRoom : Table() { val projector = bool("projector") } -object DbTimeSlot : Table() { - val id = integer("id").autoIncrement().primaryKey() - - val time = integer("time") - val duration = integer("duration") - val day = integer("day") -} - object DbSchedule : Table() { val workGroupId = integer("work_group_id").primaryKey(0) - val timeSlotId = integer("time_slot_id").primaryKey(1) - val roomId = integer("room_id").primaryKey(2) + val day = integer("day").primaryKey(1) + val time = integer("time_slot").primaryKey(2) + val roomId = integer("room_id").primaryKey(3) } enum class DbConstraintType { @@ -98,3 +93,10 @@ object DbUserPermission : Table() { val userId = integer("id").primaryKey(0) val permission = enumeration("permission", Permission::class).primaryKey(1) } + +enum class Language(val value: String) { + GERMAN("Deutsch"), ENGLISH("English"); + + override fun toString() = value + val code = value.take(2).toLowerCase() +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/Day.kt b/src/jvmMain/kotlin/de/kif/backend/model/Day.kt deleted file mode 100644 index 9461387..0000000 --- a/src/jvmMain/kotlin/de/kif/backend/model/Day.kt +++ /dev/null @@ -1,4 +0,0 @@ -package de.kif.backend.model - -class Day { -} \ No newline at end of file diff --git a/src/jvmMain/kotlin/de/kif/backend/model/Room.kt b/src/jvmMain/kotlin/de/kif/backend/model/Room.kt index 3d9ced9..c9ee9fb 100644 --- a/src/jvmMain/kotlin/de/kif/backend/model/Room.kt +++ b/src/jvmMain/kotlin/de/kif/backend/model/Room.kt @@ -34,15 +34,34 @@ class Room( suspend fun delete() { val id = id if (id >= 0) { + for (it in Schedule.getByRoom(id)) { + it.delete() + } dbQuery { DbRoom.deleteWhere { DbRoom.id eq id } } } } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Room + + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + return id + } + + companion object { - suspend fun get(roomId: Int): Room? = dbQuery { - val result = DbRoom.select { DbRoom.id eq roomId }.firstOrNull() ?: return@dbQuery null + suspend fun get(roomId: Int): Room = dbQuery { + val result = DbRoom.select { DbRoom.id eq roomId }.firstOrNull() ?: throw IllegalArgumentException() Room( result[DbRoom.id], result[DbRoom.name], diff --git a/src/jvmMain/kotlin/de/kif/backend/model/Schedule.kt b/src/jvmMain/kotlin/de/kif/backend/model/Schedule.kt new file mode 100644 index 0000000..172408b --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/Schedule.kt @@ -0,0 +1,99 @@ +package de.kif.backend.model + +import de.kif.backend.database.DbSchedule +import de.kif.backend.database.dbQuery +import org.jetbrains.exposed.sql.* + +data class Schedule( + val workGroupId: Int, + val day: Int, + val time: Int, + val roomId: Int +) { + lateinit var workGroup: WorkGroup + lateinit var room: Room + + suspend fun save() { + delete() + dbQuery { + DbSchedule.insert { + it[workGroupId] = this@Schedule.workGroupId + it[day] = this@Schedule.day + it[time] = this@Schedule.time + it[roomId] = this@Schedule.roomId + } + } + } + + suspend fun delete() { + dbQuery { + DbSchedule.deleteWhere { + (DbSchedule.workGroupId eq workGroupId) and + (DbSchedule.day eq day) and + (DbSchedule.time eq time) and + (DbSchedule.roomId eq roomId) + } + } + } + + suspend fun loadConstraints() { + try { + workGroup = WorkGroup.get(workGroupId) + room = Room.get(roomId) + } catch (e: IllegalArgumentException) { + delete() + throw e + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Schedule + + if (workGroupId != other.workGroupId) return false + + return true + } + + override fun hashCode(): Int { + return workGroupId + } + + companion object { + + private suspend fun parseQuery(block: () -> Query): List = + dbQuery { + val query = block() + query.map { result -> + Schedule( + result[DbSchedule.workGroupId], + result[DbSchedule.day], + result[DbSchedule.time], + result[DbSchedule.roomId] + ) + } + }.onEach { it.loadConstraints() } + + suspend fun getByRoom(roomId: Int): List = parseQuery { + DbSchedule.select { DbSchedule.roomId eq roomId } + } + + suspend fun getByRoom(roomId: Int, day: Int, time: Int): List = parseQuery { + DbSchedule.select { (DbSchedule.roomId eq roomId) and (DbSchedule.day eq day) and (DbSchedule.time eq time) } + } + + suspend fun getByWorkGroup(workGroupId: Int): List = parseQuery { + DbSchedule.select { DbSchedule.workGroupId eq workGroupId } + } + + suspend fun getByDay(day: Int): List = parseQuery { + DbSchedule.select { DbSchedule.day eq day } + } + + suspend fun list(): List = parseQuery { + DbSchedule.selectAll() + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/TimeSlot.kt b/src/jvmMain/kotlin/de/kif/backend/model/TimeSlot.kt deleted file mode 100644 index 37122f2..0000000 --- a/src/jvmMain/kotlin/de/kif/backend/model/TimeSlot.kt +++ /dev/null @@ -1,4 +0,0 @@ -package de.kif.backend.model - -class TimeSlot { -} \ No newline at end of file diff --git a/src/jvmMain/kotlin/de/kif/backend/model/Track.kt b/src/jvmMain/kotlin/de/kif/backend/model/Track.kt new file mode 100644 index 0000000..5c0ea16 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/Track.kt @@ -0,0 +1,63 @@ +package de.kif.backend.model + +import de.kif.backend.database.DbTrack +import de.kif.backend.database.dbQuery +import kif.common.model.Color +import org.jetbrains.exposed.sql.* +import java.lang.IllegalArgumentException + +class Track( + var id: Int = -1, + var name: String = "", + var color: Color +) { + suspend fun save() { + if (id < 0) { + dbQuery { + val newId = DbTrack.insert { + it[name] = this@Track.name + it[color] = this@Track.color.toString() + }[DbTrack.id]!! + this@Track.id = newId + } + } else { + dbQuery { + DbTrack.update({ DbTrack.id eq id }) { + it[name] = this@Track.name + it[color] = this@Track.color.toString() + } + } + } + } + + suspend fun delete() { + val id = id + if (id >= 0) { + dbQuery { + DbTrack.deleteWhere { DbTrack.id eq id } + } + } + } + + companion object { + suspend fun get(TrackId: Int): Track = dbQuery { + val result = DbTrack.select { DbTrack.id eq TrackId }.firstOrNull() ?: throw IllegalArgumentException() + Track( + result[DbTrack.id], + result[DbTrack.name], + Color.parse(result[DbTrack.color]) + ) + } + + suspend fun list(): List = dbQuery { + val query = DbTrack.selectAll() + query.map { result -> + Track( + result[DbTrack.id], + result[DbTrack.name], + Color.parse(result[DbTrack.color]) + ) + } + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/WorkGroup.kt b/src/jvmMain/kotlin/de/kif/backend/model/WorkGroup.kt index 0e30f88..6564e63 100644 --- a/src/jvmMain/kotlin/de/kif/backend/model/WorkGroup.kt +++ b/src/jvmMain/kotlin/de/kif/backend/model/WorkGroup.kt @@ -2,7 +2,9 @@ package de.kif.backend.model import de.kif.backend.database.DbWorkGroup import de.kif.backend.database.DbWorkGroupConstraint +import de.kif.backend.database.Language import de.kif.backend.database.dbQuery +import io.ktor.features.NotFoundException import org.jetbrains.exposed.sql.* class WorkGroup( @@ -13,10 +15,12 @@ class WorkGroup( var projector: Boolean = false, var resolution: Boolean = false, var length: Int = 0, + var language: Language = Language.GERMAN, var start: Long? = null, var end: Long? = null ) { var constraints: Set = emptySet() + var track: Track? = null suspend fun save() { if (id < 0) { @@ -28,6 +32,7 @@ class WorkGroup( it[projector] = this@WorkGroup.projector it[resolution] = this@WorkGroup.resolution it[length] = this@WorkGroup.length + it[language] = this@WorkGroup.language it[start] = this@WorkGroup.start it[end] = this@WorkGroup.end }[DbWorkGroup.id]!! @@ -45,6 +50,7 @@ class WorkGroup( it[projector] = this@WorkGroup.projector it[resolution] = this@WorkGroup.resolution it[length] = this@WorkGroup.length + it[language] = this@WorkGroup.language it[start] = this@WorkGroup.start it[end] = this@WorkGroup.end } @@ -60,6 +66,9 @@ class WorkGroup( suspend fun delete() { val id = id if (id >= 0) { + for (it in Schedule.getByWorkGroup(id)) { + it.delete() + } dbQuery { DbWorkGroupConstraint.deleteWhere { DbWorkGroupConstraint.workGroupId eq id } DbWorkGroup.deleteWhere { DbWorkGroup.id eq id } @@ -70,12 +79,13 @@ class WorkGroup( suspend fun loadConstraints() { if (id >= 0) { constraints = WorkGroupConstraint.get(id) + track = trackId?.let { if (it < 0) null else Track.get(it) } } } companion object { - suspend fun get(workGroupId: Int): WorkGroup? = dbQuery { - val result = DbWorkGroup.select { DbWorkGroup.id eq workGroupId }.firstOrNull() ?: return@dbQuery null + suspend fun get(workGroupId: Int): WorkGroup = dbQuery { + val result = DbWorkGroup.select { DbWorkGroup.id eq workGroupId }.firstOrNull() ?: throw IllegalArgumentException() WorkGroup( result[DbWorkGroup.id], result[DbWorkGroup.name], @@ -84,10 +94,11 @@ class WorkGroup( result[DbWorkGroup.projector], result[DbWorkGroup.resolution], result[DbWorkGroup.length], + result[DbWorkGroup.language], result[DbWorkGroup.start], result[DbWorkGroup.end] ) - }?.apply { loadConstraints() } + }.apply { loadConstraints() } suspend fun list(): List = dbQuery { val query = DbWorkGroup.selectAll() @@ -100,6 +111,7 @@ class WorkGroup( result[DbWorkGroup.projector], result[DbWorkGroup.resolution], result[DbWorkGroup.length], + result[DbWorkGroup.language], result[DbWorkGroup.start], result[DbWorkGroup.end] ) diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Calendar.kt b/src/jvmMain/kotlin/de/kif/backend/route/Calendar.kt index 00ec006..650716b 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/Calendar.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/Calendar.kt @@ -1,28 +1,490 @@ package de.kif.backend.route import de.kif.backend.LocationCalendar +import de.kif.backend.LocationLogin import de.kif.backend.PortalSession +import de.kif.backend.model.Permission +import de.kif.backend.model.Room +import de.kif.backend.model.Schedule +import de.kif.backend.model.WorkGroup +import de.kif.backend.util.Search import de.kif.backend.view.MainTemplate import de.kif.backend.view.MenuTemplate +import de.kif.backend.view.TableTemplate import io.ktor.application.call +import io.ktor.html.insert import io.ktor.html.respondHtmlTemplate import io.ktor.locations.get +import io.ktor.request.path +import io.ktor.response.respondRedirect import io.ktor.routing.Route +import io.ktor.routing.get import io.ktor.sessions.get import io.ktor.sessions.sessions -import kotlinx.html.h1 +import kif.common.model.CALENDAR_GRID_WIDTH +import kif.common.model.MessageCreateCalendarEntry +import kif.common.model.MessageDeleteCalendarEntry +import kotlinx.css.CSSBuilder +import kotlinx.css.Color +import kotlinx.css.pct +import kotlinx.css.rem +import kotlinx.html.* +import kotlin.math.max +import kotlin.math.min + +const val MINUTES_OF_DAY = 24 * 60 + +private fun DIV.calendarCell(schedule: Schedule?, day: Int, time: Int) { + if (schedule != null) { + span("calendar-entry") { + attributes["style"] = CSSBuilder().apply { + val size = schedule.workGroup.length / CALENDAR_GRID_WIDTH.toDouble() + val pos = (schedule.time % CALENDAR_GRID_WIDTH) / CALENDAR_GRID_WIDTH.toDouble() + + this.left = (pos * 100).pct + this.top = (pos * 100).pct + 0.1.rem + + this.width = (size * 100).pct + this.height = (size * 100).pct - 0.2.rem + + val c = schedule.workGroup.track?.color + if (c != null) { + backgroundColor = Color(c.toString()) + color = Color(c.textColor.toString()) + } + }.toString() + attributes["data-language"] = schedule.workGroup.language.code + attributes["data-day"] = schedule.day.toString() + attributes["data-room"] = schedule.room.id.toString() + attributes["data-time"] = schedule.time.toString() + attributes["data-cell-time"] = time.toString() + attributes["data-workgroup"] = schedule.workGroup.id.toString() + + +schedule.workGroup.name + + div("calendar-tools") { + a( + classes = "calendar-tools-m10", + href = "/calendar/$day/${schedule.room.id}/${schedule.time}/-1?next=/calendar/$day/${schedule.room.id}/${schedule.time - 10}/${schedule.workGroupId}" + ) { +"-10" } + a( + classes = "calendar-tools-m5", + href = "/calendar/$day/${schedule.room.id}/${schedule.time}/-1?next=/calendar/$day/${schedule.room.id}/${schedule.time - 5}/${schedule.workGroupId}" + ) { +"-05" } + a( + classes = "calendar-tools-reset", + href = "/calendar/$day/${schedule.room.id}/${schedule.time}/-1?next=/calendar/$day/${schedule.room.id}/$time/${schedule.workGroupId}" + ) { +"reset" } + a( + classes = "calendar-tools-p5", + href = "/calendar/$day/${schedule.room.id}/${schedule.time}/-1?next=/calendar/$day/${schedule.room.id}/${schedule.time + 5}/${schedule.workGroupId}" + ) { +"+05" } + a( + classes = "calendar-tools-p10", + href = "/calendar/$day/${schedule.room.id}/${schedule.time}/-1?next=/calendar/$day/${schedule.room.id}/${schedule.time + 10}/${schedule.workGroupId}" + ) { +"+10" } + a( + classes = "calendar-tools-del", + href = "/calendar/$day/${schedule.room.id}/${schedule.time}/-1" + ) { +"del" } + } + } + } +} + +private fun DIV.renderTimeToRoom( + day: Int, + from: Int, + to: Int, + rooms: List, + schedules: Map>, + allowEdit: Boolean +) { + val gridLabelWidth = 60 + val minutesOfDay = to - from + + div("calendar-table-time-to-room") { + div("calendar-header") { + div("calendar-cell") { + span { + +"Room" + } + } + + for (i in 0 until minutesOfDay / gridLabelWidth) { + div("calendar-cell") { + span { + val time = ((i * gridLabelWidth + from) % MINUTES_OF_DAY).let { + if (it < 0) it + MINUTES_OF_DAY else it + } + val minutes = (time % 60).toString().padStart(2, '0') + val hours = (time / 60).toString().padStart(2, '0') + +"$hours:$minutes" + } + } + } + } + + for (room in rooms) { + div("calendar-row") { + div("calendar-cell") { + span { + +room.name + } + } + + for (i in 0 until minutesOfDay / CALENDAR_GRID_WIDTH) { + val start = i * CALENDAR_GRID_WIDTH + from + val end = (i + 1) * CALENDAR_GRID_WIDTH + from - 1 + + div("calendar-cell") { + val time = i * CALENDAR_GRID_WIDTH + val minutes = (time % 60).toString().padStart(2, '0') + val hours = (time / 60).toString().padStart(2, '0') + title = "$hours:$minutes" + attributes["data-time"] = time.toString() + attributes["data-room"] = room.id.toString() + attributes["data-day"] = day.toString() + + val schedule = (start..end).mapNotNull { schedules[room]?.get(it) }.firstOrNull() + + calendarCell(schedule, day, time) + + val href = if (allowEdit) "/calendar/$day/${room.id}/$start" else null + a(href, classes = "calendar-link") + } + } + } + } + } +} + +private fun DIV.renderRoomToTime( + day: Int, + from: Int, + to: Int, + rooms: List, + schedules: Map>, + allowEdit: Boolean +) { + val gridLabelWidth = 60 + val minutesOfDay = to - from + + div("calendar-table-room-to-time") { + div("calendar-header") { + div("calendar-cell") { + span { + +"Room" + } + } + + for (room in rooms) { + div("calendar-cell") { + span { + +room.name + } + } + } + } + + for (i in 0 until minutesOfDay / CALENDAR_GRID_WIDTH) { + val time = ((i * CALENDAR_GRID_WIDTH + from) % MINUTES_OF_DAY).let { + if (it < 0) it + MINUTES_OF_DAY else it + } + val minutes = (time % 60).toString().padStart(2, '0') + val hours = (time / 60).toString().padStart(2, '0') + val timeString = "$hours:$minutes" + + val start = i * CALENDAR_GRID_WIDTH + from + val end = (i + 1) * CALENDAR_GRID_WIDTH + from - 1 + + div("calendar-row") { + div("calendar-cell") { + if (time % gridLabelWidth == 0) { + span { + +timeString + } + } + } + + for (room in rooms) { + div("calendar-cell") { + attributes["data-time"] = time.toString() + attributes["data-room"] = room.id.toString() + attributes["data-day"] = day.toString() + title = timeString + + val schedule = (start..end).mapNotNull { schedules[room]?.get(it) }.firstOrNull() + + calendarCell(schedule, day, time) + + val href = if (allowEdit) "/calendar/$day/${room.id}/$start" else null + a(href, classes = "calendar-link") + } + } + } + } + } +} fun Route.calendar() { - get { + + get("/calendar") { + call.respondRedirect("/calendar/0", true) + } + + get { param -> + call.response.cookies.append( + "orientation", + CalendarOrientation.ROOM_TO_TIME.name, + maxAge = Int.MAX_VALUE, + path = "/" + ) + call.respondRedirect("/calendar/${param.day}") + } + + get { param -> + call.response.cookies.append( + "orientation", + CalendarOrientation.TIME_TO_ROOM.name, + maxAge = Int.MAX_VALUE, + path = "/" + ) + call.respondRedirect("/calendar/${param.day}") + } + + get { param -> val user = call.sessions.get()?.getUser(call) + val allowEdit = user?.checkPermission(Permission.SCHEDULE) ?: false + val rooms = Room.list() + + val orientation = call.request.cookies["orientation"]?.let { name -> + CalendarOrientation.values().find { it.name == name } + } ?: CalendarOrientation.ROOM_TO_TIME + + val day = param.day + val h = Schedule.getByDay(day) + val schedules = h.groupBy { it.room }.mapValues { (_, it) -> + it.associateBy { + it.time + } + } + + var max = 0 + var min = 24 * 60 + for (s in h) { + max = max(max, s.time + s.workGroup.length) + min = min(min, s.time) + } + + if (min > max) { + val h1 = max + max = min + min = h1 + } + + min = (min / 60 - 1) * 60 + max = (max / 60 + 2) * 60 + + min = min(min, 0) + call.respondHtmlTemplate(MainTemplate()) { menuTemplate { this.user = user active = MenuTemplate.Tab.CALENDAR } content { - h1 { +"Calendar" } + if (rooms.isEmpty()) { + return@content + } + + div("header") { + div("header-left") { + a("/calendar/${day - 1}") { +"<" } + span { + +"Day $day" + } + a("/calendar/${day + 1}") { +">" } + } + div("header-right") { + a("/calendar/$day/room-to-time") { + +"Room to time" + } + a("/calendar/$day/time-to-room") { + +"Time to room" + } + } + } + + div("calendar") { + attributes["data-day"] = day.toString() + + div { + when (orientation) { + CalendarOrientation.ROOM_TO_TIME -> renderRoomToTime( + day, + min, + max, + rooms, + schedules, + allowEdit + ) + CalendarOrientation.TIME_TO_ROOM -> renderTimeToRoom( + day, + min, + max, + rooms, + schedules, + allowEdit + ) + } + } + } + } + + } + } + + get { param -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.SCHEDULE)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val list = WorkGroup.list() + val room = Room.get(param.room) + val day = param.day + val time = param.time + + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.WORK_GROUP + } + content { + h1 { +"Select work groups" } + insert(TableTemplate()) { + searchValue = param.search + + action { + a("/calendar/$day/${room.id}/$time/-1") { + button(classes = "form-btn btn-primary") { + +"Delete" + } + } + } + + header { + th { + +"Name" + } + th { + +"Interested" + } + th { + +"Track" + } + th { + +"Projector" + } + th { + +"Resolution" + } + th { + +"Length" + } + } + + for (u in list) { + if (Search.match(param.search, u.name)) { + val href = "/calendar/$day/${room.id}/$time/${u.id}" + entry { + attributes["data-search"] = Search.pack(u.name) + td { + a(href) { + +u.name + } + } + td { + +u.interested.toString() + } + td { + +(u.track?.name ?: "") + } + td { + +u.projector.toString() + } + td { + +u.resolution.toString() + } + td { + +u.length.toString() + } + } + } + } + } + } } } } + + get { param -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.SCHEDULE)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + for (it in Schedule.getByRoom(param.room, param.day, param.time)) { + PushService.notify( + MessageDeleteCalendarEntry( + it.day, + it.time, + it.room.id, + it.workGroup.id + ) + ) + it.delete() + } + if (param.workGroup >= 0) { + val schedule = Schedule(param.workGroup, param.day, param.time, param.room) + schedule.save() + schedule.loadConstraints() + + val cellTime = (schedule.time / 15) * 15 + + PushService.notify( + MessageCreateCalendarEntry( + schedule.day, + schedule.time, + cellTime, + schedule.room.id, + schedule.workGroup.id, + schedule.workGroup.name, + schedule.workGroup.length, + schedule.workGroup.language.code, + schedule.workGroup.track?.color + ) + ) + } + + call.respondRedirect(param.next ?: "/calendar/${param.day}") + } + } +} + +enum class CalendarOrientation { + /** + * Columns contains time + * Rows contains rooms + * + * Like the old kif tool + */ + TIME_TO_ROOM, + + /** + * Columns contains rooms + * Rows contains time + * + * Like the congress schedule + */ + ROOM_TO_TIME } diff --git a/src/jvmMain/kotlin/de/kif/backend/route/PushService.kt b/src/jvmMain/kotlin/de/kif/backend/route/PushService.kt new file mode 100644 index 0000000..ea56c29 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/PushService.kt @@ -0,0 +1,47 @@ +package de.kif.backend.route + +import io.ktor.http.cio.websocket.Frame +import io.ktor.http.cio.websocket.readText +import io.ktor.routing.Route +import io.ktor.websocket.WebSocketServerSession +import io.ktor.websocket.webSocket +import kif.common.model.Message +import kif.common.model.MessageType +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import java.lang.Exception + +object PushService { + + var clients: List = emptyList() + + suspend fun notify(messageType: MessageType) { + try { + val data = Message(messageType).stringify() + for (client in clients) { + client.outgoing.send(Frame.Text(data)) + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} + +fun Route.pushService() { + webSocket { + PushService.clients += this + + 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("onError ${closeReason.await()}") + e.printStackTrace() + PushService.clients -= this + } + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Room.kt b/src/jvmMain/kotlin/de/kif/backend/route/Room.kt index e8a0532..fc887bb 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/Room.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/Room.kt @@ -95,7 +95,7 @@ fun Route.room() { if (user == null || !user.checkPermission(Permission.ROOM)) { call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") } else { - val editRoom = Room.get(roomId.id) ?: return@get + val editRoom = Room.get(roomId.id) call.respondHtmlTemplate(MainTemplate()) { menuTemplate { this.user = user @@ -183,7 +183,7 @@ fun Route.room() { val params = call.receiveParameters().toMap().mapValues { (_, list) -> list.firstOrNull() } - val editRoom = Room.get(roomId.id) ?: return@post + val editRoom = Room.get(roomId.id) params["name"]?.let { editRoom.name = it } params["places"]?.let { editRoom.places = it.toIntOrNull() ?: 0 } @@ -298,7 +298,7 @@ fun Route.room() { if (user == null || !user.checkPermission(Permission.ROOM)) { call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") } else { - val deleteRoom = Room.get(roomId.id) ?: return@get + val deleteRoom = Room.get(roomId.id) deleteRoom.delete() diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Track.kt b/src/jvmMain/kotlin/de/kif/backend/route/Track.kt new file mode 100644 index 0000000..fa7cbb7 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/Track.kt @@ -0,0 +1,304 @@ +package de.kif.backend.route + +import de.kif.backend.LocationLogin +import de.kif.backend.LocationTrack +import de.kif.backend.PortalSession +import de.kif.backend.model.Permission +import de.kif.backend.model.Track +import de.kif.backend.util.Search +import de.kif.backend.view.MainTemplate +import de.kif.backend.view.MenuTemplate +import de.kif.backend.view.TableTemplate +import io.ktor.application.call +import io.ktor.html.insert +import io.ktor.html.respondHtmlTemplate +import io.ktor.locations.get +import io.ktor.locations.post +import io.ktor.request.path +import io.ktor.request.receiveParameters +import io.ktor.response.respondRedirect +import io.ktor.routing.Route +import io.ktor.sessions.get +import io.ktor.sessions.sessions +import io.ktor.util.toMap +import kif.common.model.Color +import kotlinx.css.CSSBuilder +import kotlinx.html.* +import kotlin.random.Random + +fun DIV.colorPicker(color: Color?) { + val colorString = color?.toString() ?: Color( + Random.nextInt(256), + Random.nextInt(256), + Random.nextInt(256) + ).toString() + + var found = false + fun DIV.generate(colorId: String, c: Color) { + val name = colorId.replace("-", " ").capitalize() + div("color-picker-entry") { + radioInput(name = "color") { + id = "color-$colorId" + value = colorId + + if (color == c) { + checked = true + found = true + } + } + label { + title = name + htmlFor = "color-$colorId" + attributes["style"] = CSSBuilder().apply { + backgroundColor = kotlinx.css.Color(c.toString()) + }.toString() + } + } + } + div("color-picker") { + for ((name, c) in Color.default) { + generate(name, c) + } + + div("color-picker-custom") { + radioInput(name = "color") { + id = "color-custom" + value = "custom" + + if (!found) { + checked = true + } + } + label { + title = "Custom" + htmlFor = "color-custom" + + input( + name = "color-input", + type = InputType.color + ) { + value = colorString + } + } + } + } +} + +fun Route.track() { + get { param -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.WORK_GROUP)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val list = Track.list() + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.WORK_GROUP + } + content { + h1 { +"Tracks" } + insert(TableTemplate()) { + searchValue = param.search + + action { + a("/track/new") { + button(classes = "form-btn btn-primary") { + +"Add track" + } + } + } + + header { + th { + +"Name" + } + th { + +"Color" + } + th(classes = "action") { + +"Action" + } + } + + for (u in list) { + if (Search.match(param.search, u.name)) { + entry { + attributes["data-search"] = Search.pack(u.name) + td { + +u.name + } + td { + +u.color.toString() + } + td(classes = "action") { + a("/track/${u.id}") { + i("material-icons") { +"edit" } + } + } + } + } + } + } + } + } + } + } + + get { trackId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.WORK_GROUP)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val editTrack = Track.get(trackId.id) + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.WORK_GROUP + } + content { + h1 { +"Edit track" } + form(method = FormMethod.post) { + div("form-group") { + label { + htmlFor = "name" + +"Name" + } + input( + name = "name", + classes = "form-control" + ) { + id = "name" + placeholder = "Name" + value = editTrack.name + } + } + div("form-group") { + colorPicker(editTrack.color) + } + + div("form-group") { + a("/track") { + button(classes = "form-btn") { + +"Cancel" + } + } + button(type = ButtonType.submit, classes = "form-btn btn-primary") { + +"Save" + } + } + } + a("/track/${editTrack.id}/delete") { + button(classes = "form-btn btn-danger") { + +"Delete" + } + } + } + } + } + } + + post { trackId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.WORK_GROUP)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val params = call.receiveParameters().toMap().mapValues { (_, list) -> + list.firstOrNull() + } + val editTrack = Track.get(trackId.id) + + params["name"]?.let { editTrack.name = it } + + editTrack.color = (params["color"] ?: return@post).let { c -> + Color.default.find { it.first == c } + }?.second ?: (params["color-input"] ?: return@post).let(Color.Companion::parse) + + editTrack.save() + + call.respondRedirect("/track") + } + } + + get { + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.WORK_GROUP)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.WORK_GROUP + } + content { + h1 { +"Create track" } + form(method = FormMethod.post) { + div("form-group") { + label { + htmlFor = "name" + +"Name" + } + input( + name = "name", + classes = "form-control" + ) { + id = "name" + placeholder = "Name" + value = "" + } + } + div("form-group") { + colorPicker(null) + } + div("form-group") { + a("/track") { + button(classes = "form-btn") { + +"Cancel" + } + } + button(type = ButtonType.submit, classes = "form-btn btn-primary") { + +"Create" + } + } + } + } + } + } + } + + post { + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.WORK_GROUP)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val params = call.receiveParameters().toMap().mapValues { (_, list) -> + list.firstOrNull() + } + + val name = params["name"] ?: return@post + val color = (params["color"] ?: return@post).let { c -> + Color.default.find { it.first == c } + }?.second ?: (params["color-input"] ?: return@post).let(Color.Companion::parse) + + Track( + name = name, + color = color + ).save() + + call.respondRedirect("/track") + } + } + + get { trackId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.WORK_GROUP)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val deleteTrack = Track.get(trackId.id) + + deleteTrack.delete() + + call.respondRedirect("/track") + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt b/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt index b24f4df..c4226f3 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt @@ -3,7 +3,9 @@ package de.kif.backend.route import de.kif.backend.LocationLogin import de.kif.backend.LocationWorkGroup import de.kif.backend.PortalSession +import de.kif.backend.database.Language import de.kif.backend.model.Permission +import de.kif.backend.model.Track import de.kif.backend.model.WorkGroup import de.kif.backend.util.Search import de.kif.backend.view.MainTemplate @@ -41,6 +43,11 @@ fun Route.workGroup() { searchValue = param.search action { + a("/track") { + button(classes = "form-btn") { + +"Edit tracks" + } + } a("/workgroup/new") { button(classes = "form-btn btn-primary") { +"Add work group" @@ -67,6 +74,9 @@ fun Route.workGroup() { th { +"Length" } + th { + +"Language" + } th(classes = "action") { +"Action" } @@ -83,7 +93,7 @@ fun Route.workGroup() { +u.interested.toString() } td { - +u.trackId.toString() + +(u.track?.name ?: "") } td { +u.projector.toString() @@ -94,9 +104,12 @@ fun Route.workGroup() { td { +u.length.toString() } + td { + +u.language.toString() + } td(classes = "action") { a("/workgroup/${u.id}") { - i("material-icons") { +"edit" } + i("material-icons") { +"edit" } } } } @@ -113,7 +126,8 @@ fun Route.workGroup() { if (user == null || !user.checkPermission(Permission.WORK_GROUP)) { call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") } else { - val editWorkGroup = WorkGroup.get(workGroupId.id) ?: return@get + val editWorkGroup = WorkGroup.get(workGroupId.id) + val tracks = Track.list() call.respondHtmlTemplate(MainTemplate()) { menuTemplate { this.user = user @@ -158,16 +172,29 @@ fun Route.workGroup() { htmlFor = "track" +"Track" } - input( - name = "track", - classes = "form-control", - type = InputType.number - ) { - id = "track" - value = editWorkGroup.trackId.toString() + div("input-group") { + select( + classes = "form-control" + ) { + name = "track" - min = "0" - max = "1337" + option { + selected = (editWorkGroup.trackId ?: -1) < 0 + value = "-1" + +"None" + } + for (track in tracks) { + option { + selected = editWorkGroup.trackId == track.id + value = track.id.toString() + +track.name + } + } + } + + a("/track", classes = "form-btn") { + i("material-icons") { +"edit" } + } } } div("form-group") { @@ -185,6 +212,26 @@ fun Route.workGroup() { min = "0" max = "1440" + step = "5" + } + } + div("form-group") { + label { + htmlFor = "language" + +"Language" + } + select( + classes = "form-control" + ) { + name = "language" + + for (language in Language.values()) { + option { + selected = editWorkGroup.language == language + value = language.name + +language.toString() + } + } } } @@ -248,7 +295,7 @@ fun Route.workGroup() { val params = call.receiveParameters().toMap().mapValues { (_, list) -> list.firstOrNull() } - val editWorkGroup = WorkGroup.get(workGroupId.id) ?: return@post + val editWorkGroup = WorkGroup.get(workGroupId.id) params["name"]?.let { editWorkGroup.name = it } params["interested"]?.toIntOrNull()?.let { editWorkGroup.interested = it } @@ -256,6 +303,7 @@ fun Route.workGroup() { params["projector"]?.let { editWorkGroup.projector = it == "on" } params["resolution"]?.let { editWorkGroup.resolution = it == "on" } params["length"]?.toIntOrNull()?.let { editWorkGroup.length = it } + params["language"]?.let { editWorkGroup.language = Language.values().find { l -> l.name == it } ?: Language.GERMAN } editWorkGroup.save() @@ -268,6 +316,7 @@ fun Route.workGroup() { if (user == null || !user.checkPermission(Permission.WORK_GROUP)) { call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") } else { + val tracks = Track.list() call.respondHtmlTemplate(MainTemplate()) { menuTemplate { this.user = user @@ -312,16 +361,22 @@ fun Route.workGroup() { htmlFor = "track" +"Track" } - input( - name = "track", - classes = "form-control", - type = InputType.number + select( + classes = "form-control" ) { - id = "track" - value = "" + name = "track" - min = "0" - max = "1337" + option { + selected = true + value = "-1" + +"None" + } + for (track in tracks) { + option { + value = track.id.toString() + +track.name + } + } } } div("form-group") { @@ -339,6 +394,26 @@ fun Route.workGroup() { min = "0" max = "1440" + step = "5" + } + } + div("form-group") { + label { + htmlFor = "language" + +"Language" + } + select( + classes = "form-control" + ) { + name = "language" + + for (language in Language.values()) { + option { + selected = language == Language.GERMAN + value = language.name + +language.toString() + } + } } } @@ -404,6 +479,7 @@ fun Route.workGroup() { val projector = params["projector"] == "on" val resolution = params["resolution"] == "on" val length = (params["length"] ?: return@post).toIntOrNull() ?: 0 + val language = (params["language"] ?: return@post).let { Language.values().find { l -> l.name == it } ?: Language.GERMAN } WorkGroup( name = name, @@ -411,7 +487,8 @@ fun Route.workGroup() { trackId = trackId, projector = projector, resolution = resolution, - length = length + length = length, + language = language ).save() call.respondRedirect("/workgroup") @@ -423,7 +500,7 @@ fun Route.workGroup() { if (user == null || !user.checkPermission(Permission.WORK_GROUP)) { call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") } else { - val deleteWorkGroup = WorkGroup.get(workGroupId.id) ?: return@get + val deleteWorkGroup = WorkGroup.get(workGroupId.id) deleteWorkGroup.delete() diff --git a/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt b/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt index abff722..a1fb02c 100644 --- a/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt +++ b/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt @@ -1,5 +1,6 @@ package de.kif.backend.view +import de.kif.backend.Resources import io.ktor.html.Placeholder import io.ktor.html.Template import io.ktor.html.TemplatePlaceholder @@ -24,12 +25,12 @@ class MainTemplate : Template { script(src = "/static/require.min.js") {} - /*script { + script { unsafe { +"require.config({baseUrl: '/static'});\n" +("require([${Resources.jsModules}]);\n") } - }*/ + } } body { insert(MenuTemplate(), menuTemplate)