Add image upload

This commit is contained in:
Lars Westermann 2019-05-30 12:32:40 +02:00
parent 997f374fe4
commit 67f24adfdf
Signed by: lars.westermann
GPG key ID: 9D417FA5BB9D5E1D
24 changed files with 614 additions and 92 deletions

View file

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

View file

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

View file

@ -5,4 +5,7 @@ import de.kif.common.SearchElement
interface Model {
val id : Long?
fun createSearch(): SearchElement
}
val createdAt: Long
val updateAt: Long
}

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,9 @@ data class User(
override val id: Long?,
val username: String,
val password: String,
val permissions: Set<Permission>
val permissions: Set<Permission>,
override val createdAt: Long = 0,
override val updateAt: Long = 0
) : Model {
fun checkPermission(permission: Permission): Boolean {

View file

@ -17,7 +17,9 @@ data class WorkGroup(
val accessible: Boolean,
val length: Int,
val language: Language,
val constraints: List<Constraint>
val constraints: List<Constraint>,
override val createdAt: Long = 0,
override val updateAt: Long = 0
) : Model {
override fun createSearch() = SearchElement(

View file

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

View file

@ -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<HTMLElement>("post-column")
val postColumnLeft = postColumn.getByClassOrCreate<HTMLElement>("post-column-left")
val postColumnRight = postColumn.getByClassOrCreate<HTMLElement>("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<HTMLElement>()
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 <reified T : HTMLElement> HTMLElement.getByClassOrCreate(name: String, newTagName: String? = null): T {
val v = this.getElementsByClassName(name)[0] as? T
if (v != null) {
return v
}
val h = createHtmlView<T>(newTagName)
h.classList.add(name)
this.appendChild(h)
return h
}

View file

@ -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;
}
}
*/

View file

@ -34,6 +34,10 @@ fun Application.main() {
files(Configuration.Path.webPath.toFile())
}
static("/images") {
files(Configuration.Path.uploadsPath.toFile())
}
// UI routes
overview()
calendar()

View file

@ -71,17 +71,30 @@ object Configuration {
val signKey by c(SecuritySpec.signKey)
}
private object GeneralSpec : ConfigSpec("general") {
val allowedUploadExtensions by required<String>("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()

View file

@ -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.
*/

View file

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

View file

@ -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<Post> {
@ -22,9 +23,13 @@ object PostRepository : Repository<Post> {
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<Post> {
}
}
val now = Date().time
return dbQuery {
if (model.pinned) {
DbPost.update({ DbPost.pinned eq true }) {
@ -51,7 +58,11 @@ object PostRepository : Repository<Post> {
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<Post> {
}
}
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<Post> {
}
}
suspend fun getPinned(): List<Post> {
private suspend fun getPinned(): List<Post> {
return dbQuery {
val result = DbPost.select { DbPost.pinned eq true }

View file

@ -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<Room> {
@ -25,7 +26,21 @@ object RoomRepository : Repository<Room> {
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<Room> {
}
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<Room> {
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<Room> {
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<Room> {
it[whiteboard] = model.whiteboard
it[blackboard] = model.blackboard
it[accessible] = model.accessible
it[updatedAt] = now
}
onUpdate.emit(model.id)

View file

@ -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<Schedule> {
@ -22,12 +23,15 @@ object ScheduleRepository : Repository<Schedule> {
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<Schedule> {
}
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<Schedule> {
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)

View file

@ -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<Track> {
@ -23,7 +24,10 @@ object TrackRepository : Repository<Track> {
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<Track> {
}
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<Track> {
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)

View file

@ -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<User> {
@ -23,11 +24,14 @@ object UserRepository : Repository<User> {
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<User> {
}
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<User> {
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 }

View file

@ -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<WorkGroup> {
@ -35,6 +36,9 @@ object WorkGroupRepository : Repository<WorkGroup> {
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<WorkGroup> {
accessible,
length,
language,
constraints
constraints,
createdAt,
updatedAt
)
}
@ -65,6 +71,8 @@ object WorkGroupRepository : Repository<WorkGroup> {
}
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<WorkGroup> {
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<WorkGroup> {
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<WorkGroup> {
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)

View file

@ -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<String, String>()
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<String, String>()
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)

View file

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