diff --git a/build.gradle b/build.gradle index b948856..4eebb90 100644 --- a/build.gradle +++ b/build.gradle @@ -21,17 +21,19 @@ group "de.kif" version "0.1.0" repositories { + mavenCentral() jcenter() maven { url 'https://jitpack.io' } maven { url "https://dl.bintray.com/kotlin/ktor" } maven { url "https://dl.bintray.com/jetbrains/markdown" } maven { url "https://kotlin.bintray.com/kotlinx" } maven { url "https://kotlin.bintray.com/kotlin-js-wrappers" } - mavenCentral() + maven { url "https://dl.bintray.com/soywiz/soywiz" } } def ktor_version = '1.1.5' def serialization_version = '0.11.0' def observable_version = '0.9.3' +def klockVersion = "1.4.0" kotlin { jvm() { @@ -58,6 +60,8 @@ kotlin { dependencies { implementation kotlin('stdlib-common') implementation "de.westermann:KObserve-metadata:$observable_version" + implementation "com.soywiz:klock-metadata:$klockVersion" + implementation "com.soywiz:klock-locale-metadata:$klockVersion" implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:$serialization_version" } @@ -89,6 +93,8 @@ kotlin { implementation 'org.mindrot:jbcrypt:0.4' implementation "de.westermann:KObserve-jvm:$observable_version" + implementation "com.soywiz:klock-jvm:$klockVersion" + implementation "com.soywiz:klock-locale-jvm:$klockVersion" implementation 'com.github.uchuhimo:konf:master-SNAPSHOT' implementation 'com.vladsch.flexmark:flexmark-all:0.42.10' @@ -110,6 +116,8 @@ kotlin { implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-js:$serialization_version" implementation "de.westermann:KObserve-js:$observable_version" + implementation "com.soywiz:klock-js:$klockVersion" + implementation "com.soywiz:klock-locale-js:$klockVersion" } } jsTest { diff --git a/src/commonMain/kotlin/de/kif/common/DateFormat.kt b/src/commonMain/kotlin/de/kif/common/DateFormat.kt new file mode 100644 index 0000000..1a40648 --- /dev/null +++ b/src/commonMain/kotlin/de/kif/common/DateFormat.kt @@ -0,0 +1,11 @@ +package de.kif.common + +import com.soywiz.klock.DateFormat +import com.soywiz.klock.KlockLocale +import com.soywiz.klock.format +import com.soywiz.klock.locale.german + +fun formatDate(unix: Long) = + DateFormat("EEEE, d. MMMM y HH:mm") + .withLocale(KlockLocale.german) + .format(unix) diff --git a/src/commonMain/kotlin/de/kif/common/model/Model.kt b/src/commonMain/kotlin/de/kif/common/model/Model.kt index 1e9e770..e7877c1 100644 --- a/src/commonMain/kotlin/de/kif/common/model/Model.kt +++ b/src/commonMain/kotlin/de/kif/common/model/Model.kt @@ -5,4 +5,7 @@ import de.kif.common.SearchElement interface Model { val id : Long? fun createSearch(): SearchElement -} \ No newline at end of file + + val createdAt: Long + val updateAt: Long +} diff --git a/src/commonMain/kotlin/de/kif/common/model/Post.kt b/src/commonMain/kotlin/de/kif/common/model/Post.kt index c53cf31..532b087 100644 --- a/src/commonMain/kotlin/de/kif/common/model/Post.kt +++ b/src/commonMain/kotlin/de/kif/common/model/Post.kt @@ -10,7 +10,10 @@ data class Post( val name: String, val content: String, val url: String, - val pinned: Boolean = false + val image: String?, + val pinned: Boolean, + override val createdAt: Long = 0, + override val updateAt: Long = 0 ) : Model { override fun createSearch() = SearchElement( @@ -22,6 +25,7 @@ data class Post( companion object { private const val chars = "abcdefghijklmnopqrstuvwxyz" private const val length = 32 + fun generateUrl() = (0 until length).asSequence() .map { Random.nextInt(chars.length) } .map { chars[it] } diff --git a/src/commonMain/kotlin/de/kif/common/model/Room.kt b/src/commonMain/kotlin/de/kif/common/model/Room.kt index d823f60..b76e2e6 100644 --- a/src/commonMain/kotlin/de/kif/common/model/Room.kt +++ b/src/commonMain/kotlin/de/kif/common/model/Room.kt @@ -12,7 +12,9 @@ data class Room( val internet: Boolean, val whiteboard: Boolean, val blackboard: Boolean, - val accessible: Boolean + val accessible: Boolean, + override val createdAt: Long = 0, + override val updateAt: Long = 0 ) : Model { override fun createSearch() = SearchElement( diff --git a/src/commonMain/kotlin/de/kif/common/model/Schedule.kt b/src/commonMain/kotlin/de/kif/common/model/Schedule.kt index bc3efad..5530c8e 100644 --- a/src/commonMain/kotlin/de/kif/common/model/Schedule.kt +++ b/src/commonMain/kotlin/de/kif/common/model/Schedule.kt @@ -9,7 +9,9 @@ data class Schedule( val workGroup: WorkGroup, val room: Room, val day: Int, - val time: Int + val time: Int, + override val createdAt: Long = 0, + override val updateAt: Long = 0 ) : Model { override fun createSearch() = SearchElement( diff --git a/src/commonMain/kotlin/de/kif/common/model/Track.kt b/src/commonMain/kotlin/de/kif/common/model/Track.kt index 5e62586..d05270a 100644 --- a/src/commonMain/kotlin/de/kif/common/model/Track.kt +++ b/src/commonMain/kotlin/de/kif/common/model/Track.kt @@ -7,7 +7,9 @@ import kotlinx.serialization.Serializable data class Track( override val id: Long?, val name: String, - val color: Color + val color: Color, + override val createdAt: Long = 0, + override val updateAt: Long = 0 ) : Model { override fun createSearch() = SearchElement( diff --git a/src/commonMain/kotlin/de/kif/common/model/User.kt b/src/commonMain/kotlin/de/kif/common/model/User.kt index 1e86af6..1b6745a 100644 --- a/src/commonMain/kotlin/de/kif/common/model/User.kt +++ b/src/commonMain/kotlin/de/kif/common/model/User.kt @@ -8,7 +8,9 @@ data class User( override val id: Long?, val username: String, val password: String, - val permissions: Set + val permissions: Set, + override val createdAt: Long = 0, + override val updateAt: Long = 0 ) : Model { fun checkPermission(permission: Permission): Boolean { diff --git a/src/commonMain/kotlin/de/kif/common/model/WorkGroup.kt b/src/commonMain/kotlin/de/kif/common/model/WorkGroup.kt index ca54cb5..dabe668 100644 --- a/src/commonMain/kotlin/de/kif/common/model/WorkGroup.kt +++ b/src/commonMain/kotlin/de/kif/common/model/WorkGroup.kt @@ -17,7 +17,9 @@ data class WorkGroup( val accessible: Boolean, val length: Int, val language: Language, - val constraints: List + val constraints: List, + override val createdAt: Long = 0, + override val updateAt: Long = 0 ) : Model { override fun createSearch() = SearchElement( diff --git a/src/jsMain/kotlin/de/kif/frontend/views/overview/OverviewMain.kt b/src/jsMain/kotlin/de/kif/frontend/views/overview/OverviewMain.kt index f9ee2c8..61ac882 100644 --- a/src/jsMain/kotlin/de/kif/frontend/views/overview/OverviewMain.kt +++ b/src/jsMain/kotlin/de/kif/frontend/views/overview/OverviewMain.kt @@ -5,9 +5,13 @@ import de.kif.frontend.launch import de.kif.frontend.repository.PostRepository import de.westermann.kobserve.event.subscribe import org.w3c.dom.HTMLElement +import org.w3c.dom.HTMLInputElement import org.w3c.dom.HTMLTextAreaElement import org.w3c.dom.events.EventListener import org.w3c.dom.get +import org.w3c.files.File +import org.w3c.files.FileReader +import org.w3c.files.get import kotlin.browser.document import kotlin.dom.clear @@ -50,6 +54,7 @@ fun initPosts() { } fun initPostEdit() { + // Content preview val textArea = document.getElementById("content") as HTMLTextAreaElement val preview = document.getElementsByClassName("post-edit-right")[0] as HTMLElement @@ -67,4 +72,45 @@ fun initPostEdit() { launch { preview.innerHTML = PostRepository.render(textArea.value) } + + // Image preview + val imageView = document.getElementsByClassName("post-edit-image")[0] as HTMLElement + val uploadButton = document.getElementById("image") as HTMLInputElement + val deleteSwitch = document.getElementById("image-delete") as? HTMLInputElement + + var file: File? = null + val original = imageView.style.backgroundImage + + fun updateImage() { + val deleteState = deleteSwitch?.checked == true + val f = file + + when { + deleteState -> { + imageView.removeAttribute("style") + uploadButton.value = "" + file = null + } + f == null -> imageView.style.backgroundImage = original + else -> { + val reader = FileReader() + reader.onload = { + val dataUrl = it.target.asDynamic().result as String + imageView.style.backgroundImage = "url(\"$dataUrl\")" + Unit + } + reader.readAsDataURL(f) + } + } + } + + uploadButton.addEventListener("change", EventListener { + val files = uploadButton.files ?: return@EventListener + file = files[0] + updateImage() + }) + + deleteSwitch?.addEventListener("change", EventListener { + updateImage() + }) } \ No newline at end of file diff --git a/src/jsMain/kotlin/de/kif/frontend/views/overview/PostView.kt b/src/jsMain/kotlin/de/kif/frontend/views/overview/PostView.kt index 5fa8254..8161782 100644 --- a/src/jsMain/kotlin/de/kif/frontend/views/overview/PostView.kt +++ b/src/jsMain/kotlin/de/kif/frontend/views/overview/PostView.kt @@ -1,5 +1,6 @@ package de.kif.frontend.views.overview +import de.kif.common.formatDate import de.kif.frontend.launch import de.kif.frontend.repository.PostRepository import de.westermann.kobserve.event.emit @@ -25,37 +26,44 @@ class PostView( } private val nameView: Link - private val contentView: View + private val contentView: HTMLElement + private val footerView: HTMLElement + private val imageView: HTMLElement private fun reload() { launch { val p = PostRepository.get(postId) ?: return@launch + classList["post-no-image"] = p.image == null + nameView.text = p.name nameView.target = "/p/${p.url}" pinned = p.pinned - contentView.html.innerHTML = PostRepository.htmlByUrl(p.url) + if (p.image == null) { + imageView.removeAttribute("style") + } else { + imageView.style.backgroundImage = "url(\"/images/${p.image}\")" + } + + contentView.innerHTML = PostRepository.htmlByUrl(p.url) + footerView.innerText = formatDate(p.createdAt) emit(PostChangeEvent(postId)) } } init { - val nameHtml = view.getElementsByClassName("post-name")[0] - nameView = nameHtml?.let { Link.wrap(it as HTMLAnchorElement) } ?: Link().also { - html.appendChild(it.html) - it.classList += "post-name" - } + nameView = Link.wrap(view.getByClassOrCreate("post-name")) - // val editHtml = view.getElementsByClassName("post-edit")[0] - // editView = editHtml?.let { Link.wrap(it as HTMLAnchorElement) } ?: Link() + val postColumn = view.getByClassOrCreate("post-column") + val postColumnLeft = postColumn.getByClassOrCreate("post-column-left") + val postColumnRight = postColumn.getByClassOrCreate("post-column-right") - val contentHtml = view.getElementsByClassName("post-content")[0] - contentView = contentHtml?.let { wrap(it as HTMLElement) } ?: wrap(createHtmlView()).also { - html.appendChild(it.html) - it.classList += "post-content" - } + imageView = postColumnLeft.getByClassOrCreate("post-image", "figure") + + contentView = postColumnRight.getByClassOrCreate("post-content") + footerView = postColumnRight.getByClassOrCreate("post-footer") PostRepository.onUpdate { if (it == postId) { @@ -69,7 +77,7 @@ class PostView( companion object { fun create(postId: Long): PostView { - val div = document.createElement("div") as HTMLElement + val div = createHtmlView() div.classList.add("post") div.dataset["id"] = postId.toString() return PostView(div).also(PostView::reload) @@ -78,3 +86,16 @@ class PostView( } data class PostChangeEvent(val id: Long) + +inline fun HTMLElement.getByClassOrCreate(name: String, newTagName: String? = null): T { + val v = this.getElementsByClassName(name)[0] as? T + + if (v != null) { + return v + } + + val h = createHtmlView(newTagName) + h.classList.add(name) + this.appendChild(h) + return h +} \ No newline at end of file diff --git a/src/jsMain/resources/style/style.scss b/src/jsMain/resources/style/style.scss index 2b753e0..f39779c 100644 --- a/src/jsMain/resources/style/style.scss +++ b/src/jsMain/resources/style/style.scss @@ -11,6 +11,7 @@ $background-primary-color: #fff; $background-secondary-color: #fcfcfc; $text-primary-color: #333; +$text-secondary-color: rgba($text-primary-color, 0.5); $primary-color: #B11D33; $primary-text-color: #fff; @@ -18,7 +19,9 @@ $primary-text-color: #fff; $error-color: #D55225; $error-text-color: #fff; -$border-color: #888; +$input-border-color: #888; +$table-border-color: rgba($text-primary-color, 0.1); +$table-header-color: rgba($text-primary-color, 0.06); $transitionTime: 150ms; @@ -40,6 +43,10 @@ body, html { height: 100%; margin: 0; padding: 0; + + & > *:last-child { + margin-bottom: 1rem; + } } .no-select { @@ -128,7 +135,7 @@ a { } &:hover { - background-color: rgba($primary-text-color, 0.1); + background-color: $table-border-color; &::after { background: $primary-color; @@ -165,8 +172,8 @@ a { position: absolute; background-color: $background-secondary-color; z-index: 5; - left: 1rem; - right: 1rem; + left: 0; + right: 0; &::before { content: ''; @@ -183,7 +190,11 @@ a { line-height: 3rem; &:after { - bottom: 0.2rem; + bottom: 0.55rem; + } + + &:last-child { + margin-bottom: 0.5rem; } } } @@ -220,7 +231,7 @@ a { } .main { - padding: 0 1rem; + //padding: 0 1rem; } .table-layout-search { @@ -252,14 +263,14 @@ a { } tr { - border-top: solid 1px rgba($text-primary-color, 0.1); + border-top: solid 1px $table-border-color; &:nth-child(odd) { //background-color: rgba($text-primary-color, 0.01); } &:first-child { - background-color: rgba($text-primary-color, 0.06); + background-color: $table-header-color; height: 2.5rem; line-height: 2.5rem; } @@ -269,7 +280,7 @@ a { line-height: 2rem; &:hover { - background-color: rgba($text-primary-color, 0.06); + background-color: $table-header-color; } } } @@ -292,20 +303,20 @@ a { z-index: 1; background: $background-primary-color; width: 100%; - border: solid 1px rgba($text-primary-color, 0.1); + border: solid 1px $table-border-color; span { padding: 0 0.5rem; &:hover { - background-color: rgba($text-primary-color, 0.06); + background-color: $table-header-color; } } } } .form-control { - border: solid 1px $border-color; + border: solid 1px $input-border-color; outline: none; padding: 0 1rem; line-height: 2.5rem; @@ -412,7 +423,7 @@ select:-moz-focusring { } .form-btn { - border: solid 1px $border-color; + border: solid 1px $input-border-color; outline: none; padding: 0 1rem; line-height: 2rem; @@ -588,7 +599,7 @@ form { .calendar[data-editable = "true"].edit { .calendar-table { width: calc(100% - 16rem); - border-right: solid 1px rgba($text-primary-color, 0.1); + border-right: solid 1px $table-border-color; } .calendar-edit-main { @@ -608,8 +619,8 @@ form { display: none; z-index: 10; - border: solid 1px $border-color; - box-shadow: 0 0.1rem 0.2rem rgba($primary-text-color); + border: solid 1px $input-border-color; + box-shadow: 0 0.1rem 0.2rem $primary-text-color; a { padding: 0.2rem; @@ -707,14 +718,14 @@ form { height: 100%; left: 0; top: 0; - border-left: solid 1px rgba($text-primary-color, 0.1); + border-left: solid 1px $table-border-color; position: absolute; } } } .calendar-row { - border-top: solid 1px rgba($text-primary-color, 0.1); + border-top: solid 1px $table-border-color; line-height: 3rem; height: 3rem; @@ -727,12 +738,12 @@ form { height: 100%; left: 0; top: 0; - border-left: solid 1px rgba($text-primary-color, 0.1); + border-left: solid 1px $table-border-color; position: absolute; } &:hover { - background-color: rgba($text-primary-color, 0.06); + background-color: $table-header-color; } .calendar-entry { @@ -749,7 +760,7 @@ form { width: 6rem; left: 0; text-align: center; - border-right: solid 1px rgba($text-primary-color, 0.1); + border-right: solid 1px $table-border-color; } .calendar-link { @@ -780,7 +791,7 @@ form { height: 100%; left: 0; top: 0; - border-left: solid 1px rgba($text-primary-color, 0.1); + border-left: solid 1px $table-border-color; position: absolute; } } @@ -800,12 +811,12 @@ form { width: 100%; left: 0; top: 0; - border-left: solid 1px rgba($text-primary-color, 0.1); + border-left: solid 1px $table-border-color; position: absolute; } &:hover { - background-color: rgba($text-primary-color, 0.06); + background-color: $table-header-color; } .calendar-entry { @@ -826,7 +837,7 @@ form { } &:nth-child(4n + 2) .calendar-cell::before { - border-top: solid 1px rgba($text-primary-color, 0.1); + border-top: solid 1px $table-border-color; } } @@ -939,13 +950,13 @@ form { display: none; background: $background-primary-color; - border: solid 1px rgba($text-primary-color, 0.1); + border: solid 1px $table-border-color; span { padding: 0 0.5rem; &:hover { - background-color: rgba($text-primary-color, 0.06); + background-color: $table-header-color; } } @@ -956,11 +967,13 @@ form { .overview { display: flex; + flex-direction: column; } .overview-main { flex-grow: 4; margin-right: 1rem; + width: 100%; } .overview-side { @@ -985,6 +998,7 @@ form { font-size: 1.2rem; color: $primary-color; line-height: 2rem; + padding: 0 1rem; &:empty::before { display: block; @@ -999,11 +1013,30 @@ form { .post-edit { position: absolute; top: 0; - right: 0; + right: 1rem; line-height: 2rem; } +.post-column { + display: flex; + flex-direction: column; +} + +.post-image { + width: 100%; + margin: 0.4rem 0 0; + padding-top: 75%; + background-position: center; + background-size: cover; +} + +.post-no-image .post-column-left { + display: none; +} + .post-content { + padding: 0 1rem; + h1, h2, h3, h4, h5, h6, p { margin: 0.7rem 0; padding: 0; @@ -1039,11 +1072,11 @@ form { } td { - border-top: solid 1px rgba($text-primary-color, 0.1); + border-top: solid 1px $table-border-color; } th { - background-color: rgba($text-primary-color, 0.06); + background-color: $table-header-color; } td, th { @@ -1051,6 +1084,14 @@ form { } } +.post-footer { + color: $text-secondary-color; + padding: 0 1rem; + font-size: 0.8rem; + font-weight: 400; + margin-top: -0.3rem; +} + .post-edit-container { display: flex; flex-direction: column; @@ -1060,29 +1101,84 @@ form { margin-left: 0; } +.post-edit-image-box { + width: 100%; + display: flex; + margin-top: 0.4rem; + + & > div:nth-child(1) { + width: 20%; + } + + & > div:nth-child(2) { + width: 80%; + padding-left: 1rem; + } +} + +.post-edit-image { + width: 100%; + padding-top: 75%; + border: solid 1px $input-border-color; + margin: 0; + background-color: $background-primary-color; + background-position: center; + background-size: cover; +} + +.overview-post { + margin-bottom: 2rem; + + &::after { + content: ''; + position: absolute; + display: block; + bottom: -1rem; + height: 1px; + left: 0; + right: 0; + background: $table-border-color; + } +} + @media (min-width: 768px) { + .post-column { + padding: 0 1rem; + } + + .overview-post::after { + left: 1rem; + right: 1rem; + } + + .post-content, .post-footer { + padding: 0; + } + + .overview-post { + .post-column { + flex-direction: row; + } + + .post-column-left { + width: 30%; + margin-right: 1rem; + } + + .post-column-right { + width: 70%; + } + } +} + +@media (min-width: 992px) { .post-edit-container { flex-direction: row; } .post-edit-right { margin-left: 2rem; } -} - -/* -.overview-post { - max-height: 20rem; - overflow: hidden; - margin-bottom: 1rem; - - &::after { - content: ''; - position: absolute; - display: block; - height: 1.5rem; - top: 18.5rem; - width: 100%; - background: linear-gradient(0deg, $background-primary-color, transparent); + .overview { + flex-direction: row; } } - */ diff --git a/src/jvmMain/kotlin/de/kif/backend/Application.kt b/src/jvmMain/kotlin/de/kif/backend/Application.kt index 0ce3257..7affb55 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Application.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Application.kt @@ -34,6 +34,10 @@ fun Application.main() { files(Configuration.Path.webPath.toFile()) } + static("/images") { + files(Configuration.Path.uploadsPath.toFile()) + } + // UI routes overview() calendar() diff --git a/src/jvmMain/kotlin/de/kif/backend/Configuration.kt b/src/jvmMain/kotlin/de/kif/backend/Configuration.kt index b7fc82e..7aa408a 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Configuration.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Configuration.kt @@ -71,17 +71,30 @@ object Configuration { val signKey by c(SecuritySpec.signKey) } + private object GeneralSpec : ConfigSpec("general") { + val allowedUploadExtensions by required("allowed_upload_extensions") + } + + object General { + val allowedUploadExtensions by c(GeneralSpec.allowedUploadExtensions) + val allowedUploadExtensionSet by lazy { + allowedUploadExtensions.split(",").map { it.trim().toLowerCase() }.toSet() + } + } + init { var config = Config { addSpec(ServerSpec) addSpec(PathSpec) addSpec(ScheduleSpec) addSpec(SecuritySpec) + addSpec(GeneralSpec) }.from.toml.resource("portal.toml") try { config = config.from.toml.file("portal.toml") - } catch (_: FileNotFoundException) { } + } catch (_: FileNotFoundException) { + } this.config = config.from.env() .from.systemProperties() diff --git a/src/jvmMain/kotlin/de/kif/backend/Resources.kt b/src/jvmMain/kotlin/de/kif/backend/Resources.kt index 492850c..c799e84 100644 --- a/src/jvmMain/kotlin/de/kif/backend/Resources.kt +++ b/src/jvmMain/kotlin/de/kif/backend/Resources.kt @@ -1,8 +1,7 @@ package de.kif.backend import mu.KotlinLogging -import java.io.File -import java.net.URI +import java.io.InputStream import java.nio.file.* /** @@ -52,6 +51,18 @@ object Resources { logger.info { "Successfully extract web content" } } + fun deleteUpload(name: String) { + Files.deleteIfExists(Configuration.Path.uploadsPath.resolve(name)) + } + + fun existsUpload(name: String): Boolean { + return Files.exists(Configuration.Path.uploadsPath.resolve(name)) + } + + fun createUpload(name: String, input: InputStream) { + Files.copy(input, Configuration.Path.uploadsPath.resolve(name)) + } + /** * List of js modules to be included. */ diff --git a/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt b/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt index cfb6b7c..2f76926 100644 --- a/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt +++ b/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt @@ -8,6 +8,9 @@ object DbTrack : Table() { val id = long("id").autoIncrement().primaryKey() val name = varchar("name", 64) val color = varchar("color", 32) + + val createdAt = long("createdAt") + val updatedAt = long("updatedAt") } object DbWorkGroup : Table() { @@ -28,6 +31,9 @@ object DbWorkGroup : Table() { val length = integer("length") val constraints = text("constraints") + + val createdAt = long("createdAt") + val updatedAt = long("updatedAt") } object DbRoom : Table() { @@ -41,6 +47,9 @@ object DbRoom : Table() { val whiteboard = bool("whiteboard") val blackboard = bool("blackboard") val accessible = bool("accessible") + + val createdAt = long("createdAt") + val updatedAt = long("updatedAt") } object DbSchedule : Table() { @@ -49,12 +58,18 @@ object DbSchedule : Table() { val roomId = long("room_id").index() val day = integer("day").index() val time = integer("time_slot") + + val createdAt = long("createdAt") + val updatedAt = long("updatedAt") } object DbUser : Table() { val id = long("id").autoIncrement().primaryKey() val username = varchar("username", 64).uniqueIndex() val password = varchar("password", 64) + + val createdAt = long("createdAt") + val updatedAt = long("updatedAt") } object DbUserPermission : Table() { @@ -68,5 +83,9 @@ object DbPost : Table() { val content = text("content") val url = varchar("url", 64).uniqueIndex() + val image = varchar("image", 64).nullable() val pinned = bool("pinned") + + val createdAt = long("createdAt") + val updatedAt = long("updatedAt") } diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/PostRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/PostRepository.kt index 16b6c42..304a0cd 100644 --- a/src/jvmMain/kotlin/de/kif/backend/repository/PostRepository.kt +++ b/src/jvmMain/kotlin/de/kif/backend/repository/PostRepository.kt @@ -10,6 +10,7 @@ import de.kif.common.model.Post import de.westermann.kobserve.event.EventHandler import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.* +import java.util.Date object PostRepository : Repository { @@ -22,9 +23,13 @@ object PostRepository : Repository { val name = row[DbPost.name] val content = row[DbPost.content] val url = row[DbPost.url] + val image = row[DbPost.image] val pinned = row[DbPost.pinned] - return Post(id, name, content, url, pinned) + val createdAt = row[DbPost.createdAt] + val updatedAt = row[DbPost.updatedAt] + + return Post(id, name, content, url, image, pinned, createdAt, updatedAt) } override suspend fun get(id: Long): Post? { @@ -40,6 +45,8 @@ object PostRepository : Repository { } } + val now = Date().time + return dbQuery { if (model.pinned) { DbPost.update({ DbPost.pinned eq true }) { @@ -51,7 +58,11 @@ object PostRepository : Repository { it[name] = model.name it[content] = model.content it[url] = model.url + it[image] = model.image it[pinned] = model.pinned + + it[createdAt] = now + it[updatedAt] = now }[DbPost.id] ?: throw IllegalStateException("Cannot create model!") onCreate.emit(id) @@ -71,12 +82,17 @@ object PostRepository : Repository { } } + val now = Date().time + dbQuery { DbPost.update({ DbPost.id eq model.id }) { it[name] = model.name it[content] = model.content it[url] = model.url + it[image] = model.image it[pinned] = model.pinned + + it[updatedAt] = now } onUpdate.emit(model.id) @@ -112,7 +128,7 @@ object PostRepository : Repository { } } - suspend fun getPinned(): List { + private suspend fun getPinned(): List { return dbQuery { val result = DbPost.select { DbPost.pinned eq true } diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/RoomRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/RoomRepository.kt index db0b2f1..557e96c 100644 --- a/src/jvmMain/kotlin/de/kif/backend/repository/RoomRepository.kt +++ b/src/jvmMain/kotlin/de/kif/backend/repository/RoomRepository.kt @@ -8,6 +8,7 @@ import de.kif.common.model.Room import de.westermann.kobserve.event.EventHandler import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.* +import java.util.Date object RoomRepository : Repository { @@ -25,7 +26,21 @@ object RoomRepository : Repository { val blackboard = row[DbRoom.blackboard] val accessible = row[DbRoom.accessible] - return Room(id, name, places, projector, internet, whiteboard, blackboard, accessible) + val createdAt = row[DbRoom.createdAt] + val updatedAt = row[DbRoom.updatedAt] + + return Room( + id, + name, + places, + projector, + internet, + whiteboard, + blackboard, + accessible, + createdAt, + updatedAt + ) } override suspend fun get(id: Long): Room? { @@ -35,6 +50,8 @@ object RoomRepository : Repository { } override suspend fun create(model: Room): Long { + val now = Date().time + return dbQuery { val id = DbRoom.insert { it[name] = model.name @@ -44,6 +61,8 @@ object RoomRepository : Repository { it[whiteboard] = model.whiteboard it[blackboard] = model.blackboard it[accessible] = model.accessible + it[createdAt] = now + it[updatedAt] = now }[DbRoom.id] ?: throw IllegalStateException("Cannot create model!") onCreate.emit(id) @@ -54,6 +73,9 @@ object RoomRepository : Repository { override suspend fun update(model: Room) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") + + val now = Date().time + dbQuery { DbRoom.update({ DbRoom.id eq model.id }) { it[name] = model.name @@ -63,6 +85,7 @@ object RoomRepository : Repository { it[whiteboard] = model.whiteboard it[blackboard] = model.blackboard it[accessible] = model.accessible + it[updatedAt] = now } onUpdate.emit(model.id) diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/ScheduleRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/ScheduleRepository.kt index 618e67b..6a23a8c 100644 --- a/src/jvmMain/kotlin/de/kif/backend/repository/ScheduleRepository.kt +++ b/src/jvmMain/kotlin/de/kif/backend/repository/ScheduleRepository.kt @@ -8,6 +8,7 @@ import de.kif.common.model.Schedule import de.westermann.kobserve.event.EventHandler import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.* +import java.util.Date object ScheduleRepository : Repository { @@ -22,12 +23,15 @@ object ScheduleRepository : Repository { val day = row[DbSchedule.day] val time = row[DbSchedule.time] + val createdAt = row[DbSchedule.createdAt] + val updatedAt = row[DbSchedule.updatedAt] + val workGroup = WorkGroupRepository.get(workGroupId) ?: throw IllegalStateException("Work group for schedule does not exist!") val room = RoomRepository.get(roomId) ?: throw IllegalStateException("Room for schedule does not exist!") - return Schedule(id, workGroup, room, day, time) + return Schedule(id, workGroup, room, day, time, createdAt, updatedAt) } override suspend fun get(id: Long): Schedule? { @@ -41,12 +45,17 @@ object ScheduleRepository : Repository { } override suspend fun create(model: Schedule): Long { + val now = Date().time + return dbQuery { val id = DbSchedule.insert { it[workGroupId] = model.workGroup.id ?: throw IllegalArgumentException("Work group does not exist!") it[roomId] = model.room.id ?: throw IllegalArgumentException("Room does not exist!") it[day] = model.day it[time] = model.time + + it[createdAt] = now + it[updatedAt] = now }[DbSchedule.id] ?: throw IllegalStateException("Cannot create model!") onCreate.emit(id) @@ -57,12 +66,17 @@ object ScheduleRepository : Repository { override suspend fun update(model: Schedule) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") + + val now = Date().time + dbQuery { DbSchedule.update({ DbSchedule.id eq model.id }) { it[workGroupId] = model.workGroup.id ?: throw IllegalArgumentException("Work group does not exist!") it[roomId] = model.room.id ?: throw IllegalArgumentException("Room does not exist!") it[day] = model.day it[time] = model.time + + it[updatedAt] = now } onUpdate.emit(model.id) diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/TrackRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/TrackRepository.kt index 4de9743..3995949 100644 --- a/src/jvmMain/kotlin/de/kif/backend/repository/TrackRepository.kt +++ b/src/jvmMain/kotlin/de/kif/backend/repository/TrackRepository.kt @@ -11,6 +11,7 @@ import de.kif.common.model.parseColor import de.westermann.kobserve.event.EventHandler import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.* +import java.util.Date object TrackRepository : Repository { @@ -23,7 +24,10 @@ object TrackRepository : Repository { val name = row[DbTrack.name] val color = row[DbTrack.color].parseColor() - return Track(id, name, color) + val createdAt = row[DbTrack.createdAt] + val updatedAt = row[DbTrack.updatedAt] + + return Track(id, name, color, createdAt, updatedAt) } override suspend fun get(id: Long): Track? { @@ -33,10 +37,15 @@ object TrackRepository : Repository { } override suspend fun create(model: Track): Long { + val now = Date().time + return dbQuery { val id = DbTrack.insert { it[name] = model.name it[color] = model.color.toString() + + it[createdAt] = now + it[updatedAt] = now }[DbTrack.id] ?: throw IllegalStateException("Cannot create model!") onCreate.emit(id) @@ -47,10 +56,15 @@ object TrackRepository : Repository { override suspend fun update(model: Track) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") + + val now = Date().time + dbQuery { DbTrack.update({ DbTrack.id eq model.id }) { it[name] = model.name it[color] = model.color.toString() + + it[updatedAt] = now } onUpdate.emit(model.id) diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/UserRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/UserRepository.kt index f851f2d..ddb09c4 100644 --- a/src/jvmMain/kotlin/de/kif/backend/repository/UserRepository.kt +++ b/src/jvmMain/kotlin/de/kif/backend/repository/UserRepository.kt @@ -11,6 +11,7 @@ import de.kif.common.model.User import de.westermann.kobserve.event.EventHandler import kotlinx.coroutines.runBlocking import org.jetbrains.exposed.sql.* +import java.util.Date object UserRepository : Repository { @@ -23,11 +24,14 @@ object UserRepository : Repository { val username = row[DbUser.username] val password = row[DbUser.password] + val createdAt = row[DbUser.createdAt] + val updatedAt = row[DbUser.updatedAt] + val permissions = DbUserPermission.slice(DbUserPermission.permission).select { DbUserPermission.userId eq id }.map { it[DbUserPermission.permission] }.toSet() - return User(id, username, password, permissions) + return User(id, username, password, permissions, createdAt, updatedAt) } override suspend fun get(id: Long): User? { @@ -37,10 +41,15 @@ object UserRepository : Repository { } override suspend fun create(model: User): Long { + val now = Date().time + return dbQuery { val id = DbUser.insert { it[username] = model.username it[password] = model.password + + it[createdAt] = now + it[updatedAt] = now }[DbUser.id] ?: throw IllegalStateException("Cannot create model!") for (permission in model.permissions) { @@ -58,10 +67,15 @@ object UserRepository : Repository { override suspend fun update(model: User) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") + + val now = Date().time + dbQuery { DbUser.update({ DbUser.id eq model.id }) { it[username] = model.username it[password] = model.password + + it[updatedAt] = now } DbUserPermission.deleteWhere { DbUserPermission.userId eq model.id } diff --git a/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt b/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt index 5d22d37..a4fbdce 100644 --- a/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt +++ b/src/jvmMain/kotlin/de/kif/backend/repository/WorkGroupRepository.kt @@ -13,6 +13,7 @@ import de.westermann.kobserve.event.EventHandler import kotlinx.coroutines.runBlocking import kotlinx.serialization.list import org.jetbrains.exposed.sql.* +import java.util.Date object WorkGroupRepository : Repository { @@ -35,6 +36,9 @@ object WorkGroupRepository : Repository { val language = row[DbWorkGroup.language] val constraints = Message.json.parse(Constraint.serializer().list, row[DbWorkGroup.constraints]) + val createdAt = row[DbWorkGroup.createdAt] + val updatedAt = row[DbWorkGroup.updatedAt] + val track = trackId?.let { TrackRepository.get(it) } return WorkGroup( @@ -50,7 +54,9 @@ object WorkGroupRepository : Repository { accessible, length, language, - constraints + constraints, + createdAt, + updatedAt ) } @@ -65,6 +71,8 @@ object WorkGroupRepository : Repository { } override suspend fun create(model: WorkGroup): Long { + val now = Date().time + return dbQuery { val id = DbWorkGroup.insert { it[name] = model.name @@ -79,6 +87,9 @@ object WorkGroupRepository : Repository { it[length] = model.length it[language] = model.language it[constraints] = Message.json.stringify(Constraint.serializer().list, model.constraints) + + it[createdAt] = now + it[updatedAt] = now }[DbWorkGroup.id] ?: throw IllegalStateException("Cannot create model!") onCreate.emit(id) @@ -89,6 +100,9 @@ object WorkGroupRepository : Repository { override suspend fun update(model: WorkGroup) { if (model.id == null) throw IllegalStateException("Cannot update model which was not created!") + + val now = Date().time + dbQuery { DbWorkGroup.update({ DbWorkGroup.id eq model.id }) { it[name] = model.name @@ -103,6 +117,8 @@ object WorkGroupRepository : Repository { it[length] = model.length it[language] = model.language it[constraints] = Message.json.stringify(Constraint.serializer().list, model.constraints) + + it[updatedAt] = now } onUpdate.emit(model.id) diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Overview.kt b/src/jvmMain/kotlin/de/kif/backend/route/Overview.kt index a60cfe7..31bc748 100644 --- a/src/jvmMain/kotlin/de/kif/backend/route/Overview.kt +++ b/src/jvmMain/kotlin/de/kif/backend/route/Overview.kt @@ -1,30 +1,39 @@ package de.kif.backend.route +import de.kif.backend.Configuration +import de.kif.backend.Resources import de.kif.backend.authenticateOrRedirect import de.kif.backend.isAuthenticated import de.kif.backend.repository.PostRepository import de.kif.backend.util.markdownToHtml import de.kif.backend.view.MainTemplate import de.kif.backend.view.MenuTemplate +import de.kif.common.formatDate import de.kif.common.model.Permission import de.kif.common.model.Post import io.ktor.application.call import io.ktor.html.respondHtmlTemplate import io.ktor.http.HttpStatusCode -import io.ktor.request.receiveParameters +import io.ktor.http.content.PartData +import io.ktor.http.content.forEachPart +import io.ktor.http.content.streamProvider +import io.ktor.request.receiveMultipart import io.ktor.response.respond import io.ktor.response.respondRedirect import io.ktor.routing.Route import io.ktor.routing.get import io.ktor.routing.post -import io.ktor.util.toMap import kotlinx.html.* +import java.io.File fun DIV.createPost(post: Post, editable: Boolean = false, additionalClasses: String = "") { var classes = "post" if (additionalClasses.isNotBlank()) { classes += " $additionalClasses" } + if (post.image == null) { + classes += " post-no-image" + } div(classes) { attributes["data-id"] = post.id.toString() attributes["data-pinned"] = post.pinned.toString() @@ -37,9 +46,24 @@ fun DIV.createPost(post: Post, editable: Boolean = false, additionalClasses: Str i("material-icons") { +"edit" } } } - div("post-content") { - unsafe { - raw(markdownToHtml(post.content)) + + div("post-column") { + div("post-column-left") { + figure(classes = "post-image") { + if (post.image != null) { + attributes["style"] = "background-image: url(\"/images/${post.image}\")" + } + } + } + div("post-column-right") { + div("post-content") { + unsafe { + raw(markdownToHtml(post.content)) + } + } + div("post-footer") { + +formatDate(post.createdAt) + } } } } @@ -118,7 +142,7 @@ fun Route.overview() { h1 { +"Edit post" } div("post-edit-container") { div("post-edit-left") { - form(method = FormMethod.post) { + form(method = FormMethod.post, encType = FormEncType.multipartFormData) { div("form-group") { label { htmlFor = "name" @@ -149,6 +173,54 @@ fun Route.overview() { } } + label { + htmlFor = "image" + +"Image" + } + div("post-edit-image-box") { + div { + figure(classes = "post-edit-image") { + if (editPost.image != null) { + attributes["style"] = + "background-image: url(\"/images/${editPost.image}\")" + } + } + } + div { + div("form-group") { + input( + name = "image", + classes = "form-btn", + type = InputType.file + ) { + id = "image" + value = "Upload image" + accept = + Configuration.General + .allowedUploadExtensionSet + .joinToString(",") { + ".$it" + } + } + } + + div("form-group form-switch") { + input( + name = "image-delete", + classes = "form-control", + type = InputType.checkBox + ) { + id = "image-delete" + checked = false + } + label { + htmlFor = "image-delete" + +"Delete image" + } + } + } + } + div("form-switch-group") { div("form-group form-switch") { input( @@ -208,9 +280,37 @@ fun Route.overview() { post("/post/{id}") { authenticateOrRedirect(Permission.POST) { user -> val postId = call.parameters["id"]?.toLongOrNull() ?: return@post - val params = call.receiveParameters().toMap().mapValues { (_, list) -> - list.firstOrNull() + + var imageUploadName: String? = null + + val params = mutableMapOf() + call.receiveMultipart().forEachPart { part -> + val name = part.name ?: return@forEachPart + when (part) { + is PartData.FormItem -> { + params[name] = part.value + } + is PartData.FileItem -> { + val extension = File(part.originalFileName).extension + + if (extension.toLowerCase() !in Configuration.General.allowedUploadExtensionSet) return@forEachPart + + var uploadName = Post.generateUrl() + "." + extension + + while (true) { + if (Resources.existsUpload(uploadName)) { + uploadName = Post.generateUrl() + "." + extension + } else { + break + } + } + + Resources.createUpload(uploadName, part.streamProvider()) + imageUploadName = uploadName + } + } } + var post = PostRepository.get(postId) ?: return@post params["name"]?.let { post = post.copy(name = it) } @@ -218,6 +318,28 @@ fun Route.overview() { params["content"]?.let { post = post.copy(content = it) } params["pinned"]?.let { post = post.copy(pinned = it == "on") } + if (params["image-delete"] == "on") { + val currentImage = post.image + if (currentImage != null) { + post = post.copy(image = null) + Resources.deleteUpload(currentImage) + } + + val upload = imageUploadName + if (upload != null) { + Resources.deleteUpload(upload) + } + } else { + imageUploadName?.let { + val currentImage = post.image + if (currentImage != null) { + Resources.deleteUpload(currentImage) + } + + post = post.copy(image = it) + } + } + PostRepository.update(post) call.respondRedirect("/") @@ -235,7 +357,7 @@ fun Route.overview() { h1 { +"Create post" } div("post-edit-container") { div("post-edit-left") { - form(method = FormMethod.post) { + form(method = FormMethod.post, encType = FormEncType.multipartFormData) { div("form-group") { label { htmlFor = "name" @@ -265,6 +387,34 @@ fun Route.overview() { } } + label { + htmlFor = "image" + +"Image" + } + div("post-edit-image-box") { + div { + figure(classes = "post-edit-image") {} + } + div { + div("form-group") { + input( + name = "image", + classes = "form-btn", + type = InputType.file + ) { + id = "image" + value = "Upload image" + accept = + Configuration.General + .allowedUploadExtensionSet + .joinToString(",") { + ".$it" + } + } + } + } + } + div("form-switch-group") { div("form-group form-switch") { input( @@ -318,8 +468,34 @@ fun Route.overview() { post("/post/new") { authenticateOrRedirect(Permission.POST) { user -> - val params = call.receiveParameters().toMap().mapValues { (_, list) -> - list.firstOrNull() + var imageUploadName: String? = null + + val params = mutableMapOf() + call.receiveMultipart().forEachPart { part -> + val name = part.name ?: return@forEachPart + when (part) { + is PartData.FormItem -> { + params[name] = part.value + } + is PartData.FileItem -> { + val extension = File(part.originalFileName).extension + + if (extension.toLowerCase() !in Configuration.General.allowedUploadExtensionSet) return@forEachPart + + var uploadName = Post.generateUrl() + "." + extension + + while (true) { + if (Resources.existsUpload(uploadName)) { + uploadName = Post.generateUrl() + "." + extension + } else { + break + } + } + + Resources.createUpload(uploadName, part.streamProvider()) + imageUploadName = uploadName + } + } } val name = params["name"] ?: return@post @@ -327,7 +503,7 @@ fun Route.overview() { val url = params["url"] ?: return@post val pinned = params["pinned"] == "on" - val post = Post(null, name, content, url, pinned) + val post = Post(null, name, content, url, imageUploadName, pinned) PostRepository.create(post) diff --git a/src/jvmMain/resources/portal.toml b/src/jvmMain/resources/portal.toml index c2d6147..818b852 100644 --- a/src/jvmMain/resources/portal.toml +++ b/src/jvmMain/resources/portal.toml @@ -14,3 +14,6 @@ reference = "2019-03-27" [security] session_name = "SESSION" sign_key = "d1 20 23 8c 01 f8 f0 0d 9d 7c ff 68 21 97 75 31 38 3f fb 91 20 3a 8d 86 d4 e9 d8 50 f8 71 f1 dc" + +[general] +allowed_upload_extensions = "png, jpg, jpeg" \ No newline at end of file