diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77c01ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +.idea/ +build/ +web/ + +*.swp +*.swo + +*.db diff --git a/LICENSE b/LICENSE index 00d9714..17a5346 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ MIT License -Copyright (c) +Copyright (c) 2019 Lars Westermann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index e1cfe44..3b91bef 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ # portal -Webportal for everything and stuff \ No newline at end of file +Webportal for everything and stuff + +## Usage + +The server can be started directly via: +```bash +./gradlew run +``` + +Or create a shadow jar: +```bash +./gradlew jar +java -jar build/libs/portal.jar +``` diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..58e9f96 --- /dev/null +++ b/build.gradle @@ -0,0 +1,183 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +buildscript { + repositories { + jcenter() + + maven { + url "https://plugins.gradle.org/m2/" + } + } +} + +plugins { + id 'kotlin-multiplatform' version '1.3.20' + id 'kotlinx-serialization' version '1.3.20' + id "org.kravemir.gradle.sass" version "1.2.2" + id "com.github.johnrengelman.shadow" version "4.0.4" +} + +group "de.kif" +version "0.1.0" + +repositories { + jcenter() + maven { url "http://dl.bintray.com/kotlin/ktor" } + maven { url "https://kotlin.bintray.com/kotlinx" } + mavenCentral() +} +def ktor_version = '1.1.2' +def serialization_version = '0.10.0' + +kotlin { + jvm() { + compilations.all { + kotlinOptions { + freeCompilerArgs += [ + "-Xuse-experimental=io.ktor.locations.KtorExperimentalLocationsAPI", + "-Xuse-experimental=io.ktor.util.KtorExperimentalAPI" + ] + } + } + } + js() { + compilations.all { + kotlinOptions { + moduleKind = "umd" + sourceMap = true + metaInfo = true + } + } + } + sourceSets { + commonMain { + dependencies { + implementation kotlin('stdlib-common') + + implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:$serialization_version" + } + } + commonTest { + dependencies { + implementation kotlin('test-common') + implementation kotlin('test-annotations-common') + } + } + jvmMain { + dependencies { + implementation kotlin('stdlib-jdk8') + + implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:$serialization_version" + + 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-html-builder:$ktor_version" + + implementation 'org.xerial:sqlite-jdbc:3.25.2' + implementation 'org.jetbrains.exposed:exposed:0.12.2' + + implementation 'org.mindrot:jbcrypt:0.4' + + api 'io.github.microutils:kotlin-logging:1.6.23' + api 'ch.qos.logback:logback-classic:1.2.3' + api 'org.fusesource.jansi:jansi:1.8' + } + } + jvmTest { + dependencies { + implementation kotlin('test') + implementation kotlin('test-junit') + } + } + jsMain { + dependencies { + implementation kotlin('stdlib-js') + + implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime-js:$serialization_version" + + implementation "de.westermann:KObserve-js:0.8.0" + } + } + jsTest { + dependencies { + implementation kotlin('test-js') + } + } + } +} + +sass { + main { + srcDir = file("$projectDir/src/jsMain/resources/style") + outDir = file("$buildDir/processedResources/js/main/style") + + exclude = "**/*.css" + } +} + + +def webFolder = new File(project.buildDir, "../web") +def jsCompilations = kotlin.targets.js.compilations + +task populateWebFolder(dependsOn: [jsMainClasses, sass]) { + doLast { + copy { + from jsCompilations.main.output + from kotlin.sourceSets.jsMain.resources.srcDirs + jsCompilations.test.runtimeDependencyFiles.each { + if (it.exists() && !it.isDirectory()) { + from zipTree(it.absolutePath).matching { + include '*.js' + exclude '*.meta.js' + } + } + } + into webFolder + } + } +} + +jsJar.dependsOn(populateWebFolder) + +def mainClassName = 'de.kif.backend.Main' + +task run(type: JavaExec, dependsOn: [jvmMainClasses, jsJar]) { + main = mainClassName + classpath { + [ + kotlin.targets.jvm.compilations.main.output.allOutputs.files, + configurations.jvmRuntimeClasspath, + ] + } + args = [] +} + +clean.doFirst { + delete webFolder +} + +task jar(type: ShadowJar, dependsOn: [jvmMainClasses, jsMainClasses, sass]) { + from kotlin.targets.jvm.compilations.main.output + + from(kotlin.targets.js.compilations.main.output) { + into "web" + exclude '*.meta.js' + } + from(kotlin.sourceSets.jsMain.resources.srcDirs) { + into "web" + exclude '*.meta.js' + } + + configurations = [kotlin.targets.jvm.compilations.main.compileDependencyFiles] + + baseName = rootProject.name + classifier = null + version = null + + manifest { + attributes 'Main-Class': mainClassName + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..29e08e8 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..87b738c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7cae24e --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Feb 05 15:31:59 CET 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..af6708f --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..0f8d593 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..0c780ba --- /dev/null +++ b/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + resolutionStrategy { + eachPlugin { + if (requested.id.id == "kotlin-multiplatform") { + useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:${requested.version}") + } + if (requested.id.id == "kotlinx-serialization") { + useModule("org.jetbrains.kotlin:kotlin-serialization:${requested.version}") + } + } + } +} +rootProject.name = 'portal' diff --git a/src/jsMain/kotlin/de/kif/frontend/calendar/Calendar.kt b/src/jsMain/kotlin/de/kif/frontend/calendar/Calendar.kt new file mode 100644 index 0000000..3a3f790 --- /dev/null +++ b/src/jsMain/kotlin/de/kif/frontend/calendar/Calendar.kt @@ -0,0 +1,11 @@ +package de.kif.frontend.calendar + +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView + +class Calendar : ViewCollection(createHtmlView()) { + init { + + } +} \ No newline at end of file diff --git a/src/jsMain/kotlin/de/kif/frontend/main.kt b/src/jsMain/kotlin/de/kif/frontend/main.kt new file mode 100644 index 0000000..cb72995 --- /dev/null +++ b/src/jsMain/kotlin/de/kif/frontend/main.kt @@ -0,0 +1,19 @@ +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.kwebview.components.init + +fun main() = init { + clear() + h1("Test") + boxView { + style { + width = "600px" + height = "400px" + margin = "10px" + } + +Calendar() + } +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/AttributeDelegate.kt b/src/jsMain/kotlin/de/westermann/kwebview/AttributeDelegate.kt new file mode 100644 index 0000000..2c8a3e7 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/AttributeDelegate.kt @@ -0,0 +1,25 @@ +package de.westermann.kwebview + +import kotlin.reflect.KProperty + +/** + * Delegate to easily access html attributes. + * + * @author lars + */ +class AttributeDelegate( + private val paramName: String? = null +) { + + private fun getParamName(property: KProperty<*>): String = paramName ?: property.name.toLowerCase() + + operator fun getValue(container: View, property: KProperty<*>) = container.html.getAttribute(getParamName(property)) + + operator fun setValue(container: View, property: KProperty<*>, value: String?) { + if (value == null) { + container.html.removeAttribute(getParamName(property)) + } else { + container.html.setAttribute(getParamName(property), value.toString()) + } + } +} \ 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 new file mode 100644 index 0000000..e61e796 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/ClassDelegate.kt @@ -0,0 +1,49 @@ +package de.westermann.kwebview + +import de.westermann.kobserve.Property +import de.westermann.kobserve.basic.property +import kotlin.reflect.KProperty + +/** + * Delegate to easily set css classes as boolean attributes. + * + * @author lars + */ +class ClassDelegate( + className: String? = null +) { + + private lateinit var container: View + private lateinit var paramName: String + private lateinit var classProperty: Property + + operator fun getValue(container: View, property: KProperty<*>): Property { + if (!this::container.isInitialized) { + this.container = container + } + + if (!this::paramName.isInitialized) { + var name = property.name.toDashCase() + if (name.endsWith("-property")) { + name = name.replace("-property", "") + } + paramName = name + } + + if (!this::classProperty.isInitialized) { + classProperty = property(container.html.classList.contains(paramName)) + + classProperty.onChange { + container.html.classList.toggle(paramName, classProperty.value) + } + } + + return classProperty + } + + init { + if (className != null) { + this.paramName = className + } + } +} \ No newline at end of file diff --git a/src/jsMain/kotlin/de/westermann/kwebview/ClassList.kt b/src/jsMain/kotlin/de/westermann/kwebview/ClassList.kt new file mode 100644 index 0000000..4921668 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/ClassList.kt @@ -0,0 +1,119 @@ +package de.westermann.kwebview + +import de.westermann.kobserve.ListenerReference +import de.westermann.kobserve.Property +import de.westermann.kobserve.ReadOnlyProperty +import org.w3c.dom.DOMTokenList + +/** + * Represents the css classes of an html element. + * + * @author lars + */ +class ClassList( + private val list: DOMTokenList +) : Iterable { + + private val bound: MutableMap = mutableMapOf() + + + /** + * Add css class. + */ + fun add(clazz: String) { + if (clazz in bound) { + val p = bound[clazz] ?: return + if (p.property is Property) { + p.property.value = true + } else { + throw IllegalStateException("The given class is bound and cannot be modified manually!") + } + } else { + list.add(clazz) + } + } + + /** + * Add css class. + */ + operator fun plusAssign(clazz: String) = add(clazz) + + /** + * Add css class. + */ + fun remove(clazz: String) { + if (clazz in bound) { + val p = bound[clazz] ?: return + if (p.property is Property) { + p.property.value = false + } else { + throw IllegalStateException("The given class is bound and cannot be modified manually!") + } + } else { + list.remove(clazz) + } + } + + /** + * Remove css class. + */ + operator fun minusAssign(clazz: String) = remove(clazz) + + /** + * Check if css class exits. + */ + operator fun get(clazz: String): Boolean = list.contains(clazz) + + /** + * Check if css class exits. + */ + operator fun contains(clazz: String): Boolean = list.contains(clazz) + + /** + * Set css class present. + */ + operator fun set(clazz: String, present: Boolean) = + if (present) { + add(clazz) + } else { + remove(clazz) + } + + /** + * Toggle css class. + */ + fun toggle(clazz: String, force: Boolean? = null) = set(clazz, force ?: !contains(clazz)) + + fun bind(clazz: String, property: ReadOnlyProperty) { + if (clazz in bound) { + throw IllegalArgumentException("Class is already bound!") + } + + set(clazz, property.value) + bound[clazz] = Bound(property, + property.onChange.reference { + list.toggle(clazz, property.value) + } + ) + } + + fun unbind(clazz: String) { + if (clazz !in bound) { + throw IllegalArgumentException("Class is not bound!") + } + + bound[clazz]?.reference?.remove() + bound -= clazz + } + + override fun iterator(): Iterator { + return toString().split(" +".toRegex()).iterator() + } + + override fun toString(): String = list.value + + private data class Bound( + val property: ReadOnlyProperty, + val reference: ListenerReference? + ) +} \ 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 new file mode 100644 index 0000000..5103d75 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/DataSet.kt @@ -0,0 +1,128 @@ +package de.westermann.kwebview + +import de.westermann.kobserve.ListenerReference +import de.westermann.kobserve.Property +import de.westermann.kobserve.ReadOnlyProperty +import org.w3c.dom.DOMStringMap +import org.w3c.dom.get +import org.w3c.dom.set + +/** + * Represents the css classes of an html element. + * + * @author lars + */ +class DataSet( + private val map: DOMStringMap +) { + + private val bound: MutableMap = mutableMapOf() + + /** + * Add css class. + */ + operator fun plusAssign(entry: Pair) { + if (entry.first in bound) { + bound[entry.first]?.set(entry.second) + } else { + map[entry.first] = entry.second + } + } + + /** + * Remove css class. + */ + operator fun minusAssign(key: String) { + if (key in bound) { + bound[key]?.set(null) + } else { + delete(map, key) + } + } + + /** + * Check if css class exits. + */ + operator fun get(key: String): String? = map[key] + + /** + * Set css class present. + */ + operator fun set(key: String, value: String?) = + if (value == null) { + this -= key + } else { + this += key to value + } + + fun bind(key: String, property: ReadOnlyProperty) { + if (key in bound) { + throw IllegalArgumentException("Class is already bound!") + } + + bound[key] = Bound(key, null, property) + } + + fun bind(key: String, property: ReadOnlyProperty) { + if (key in bound) { + throw IllegalArgumentException("Class is already bound!") + } + + bound[key] = Bound(key, property, null) + } + + fun unbind(key: String) { + if (key !in bound) { + throw IllegalArgumentException("Class is not bound!") + } + + bound[key]?.reference?.remove() + bound -= key + } + + private inner class Bound( + val key: String, + val propertyNullable: ReadOnlyProperty?, + val property: ReadOnlyProperty? + ) { + + var reference: ListenerReference? = null + + fun set(value: String?) { + if (propertyNullable != null && propertyNullable is Property) { + propertyNullable.value = value + } else if (property != null && property is Property && value != null) { + property.value = value + } else { + throw IllegalStateException("The given class is bound and cannot be modified manually!") + } + } + + init { + if (propertyNullable != null) { + reference = propertyNullable.onChange.reference { + val value = propertyNullable.value + if (value == null) { + delete(map, key) + } else { + map[key] = value + } + } + + val value = propertyNullable.value + if (value == null) { + delete(map, key) + } else { + map[key] = value + } + } else if (property != null) { + reference = property.onChange.reference { + map[key] = property.value + } + + map[key] = property.value + } + + } + } +} \ No newline at end of file diff --git a/src/jsMain/kotlin/de/westermann/kwebview/Dimension.kt b/src/jsMain/kotlin/de/westermann/kwebview/Dimension.kt new file mode 100644 index 0000000..72c39fb --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/Dimension.kt @@ -0,0 +1,62 @@ +package de.westermann.kwebview + +import kotlin.math.abs +import kotlin.math.min + +/** + * @author lars + */ +data class Dimension( + val left: Double, + val top: Double, + val width: Double = 0.0, + val height: Double = 0.0 +) { + + constructor(position: Point, size: Point = Point.ZERO) : this(position.x, position.y, size.x, size.y) + + val position: Point + get() = Point(left, top) + + val size: Point + get() = Point(width, height) + + val right: Double + get() = left + width + + val bottom: Double + get() = top + height + + val edges: Set + get() = setOf( + Point(left, top), + Point(right, top), + Point(left, bottom), + Point(right, bottom) + ) + + val normalized: Dimension + get() { + val l = min(left, right) + val t = min(top, bottom) + return Dimension(l, t, abs(width), abs(width)) + } + + operator fun contains(other: Dimension): Boolean = !(other.left > right || + other.right < left || + other.top > bottom || + other.bottom < top) + + + operator fun contains(other: Point): Boolean { + val n = normalized + return (n.left <= other.x && (n.left + width) >= other.x) + && (n.top <= other.y && (n.top + height) >= other.y) + } + + operator fun plus(point: Point) = copy(left + point.x, top + point.y) + + companion object { + val ZERO = Dimension(0.0, 0.0) + } +} \ No newline at end of file diff --git a/src/jsMain/kotlin/de/westermann/kwebview/KWebViewDsl.kt b/src/jsMain/kotlin/de/westermann/kwebview/KWebViewDsl.kt new file mode 100644 index 0000000..43502bb --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/KWebViewDsl.kt @@ -0,0 +1,4 @@ +package de.westermann.kwebview + +@DslMarker +annotation class KWebViewDsl diff --git a/src/jsMain/kotlin/de/westermann/kwebview/Point.kt b/src/jsMain/kotlin/de/westermann/kwebview/Point.kt new file mode 100644 index 0000000..2d41d0f --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/Point.kt @@ -0,0 +1,50 @@ +package de.westermann.kwebview + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sqrt + +/** + * @author lars + */ +data class Point( + val x: Double, + val y: Double +) { + constructor(x: Int, y: Int) : this(x.toDouble(), y.toDouble()) + + operator fun plus(number: Int) = Point(x + number, y + number) + operator fun plus(number: Double) = Point(x + number, y + number) + operator fun plus(point: Point) = Point(x + point.x, y + point.y) + + operator fun minus(number: Int) = Point(x - number, y - number) + operator fun minus(number: Double) = Point(x - number, y - number) + operator fun minus(point: Point) = Point(x - point.x, y - point.y) + + operator fun times(number: Int) = Point(x * number, y * number) + operator fun times(number: Double) = Point(x * number, y * number) + operator fun times(point: Point) = Point(x * point.x, y * point.y) + + operator fun div(number: Int) = Point(x / number, y / number) + operator fun div(number: Double) = Point(x / number, y / number) + operator fun div(point: Point) = Point(x / point.x, y / point.y) + + operator fun unaryMinus(): Point = Point(-x, -y) + + fun min(): Double = min(x, y) + fun max(): Double = max(x, y) + + val isZero: Boolean + get() = x == 0.0 && y == 0.0 + + companion object { + val ZERO = Point(0.0, 0.0) + } + + val asPx: String + get() = "${x}px, ${y}px" + + fun distance(): Double = sqrt(x * x + y * y) + + infix fun distance(other: Point) = (this - other).distance() +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/View.kt b/src/jsMain/kotlin/de/westermann/kwebview/View.kt new file mode 100644 index 0000000..7090b93 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/View.kt @@ -0,0 +1,124 @@ +package de.westermann.kwebview + +import de.westermann.kobserve.EventHandler +import org.w3c.dom.HTMLElement +import org.w3c.dom.css.CSSStyleDeclaration +import org.w3c.dom.events.FocusEvent +import org.w3c.dom.events.KeyboardEvent +import org.w3c.dom.events.MouseEvent +import org.w3c.dom.events.WheelEvent + +abstract class View(view: HTMLElement = createHtmlView()) { + + open val html: HTMLElement = view.also { view -> + this::class.simpleName?.let { name -> + view.classList.add(name.toDashCase()) + } + } + + val classList = ClassList(view.classList) + val dataset = DataSet(view.dataset) + + var id by AttributeDelegate() + + val clientLeft: Int + get() = html.clientLeft + val clientTop: Int + get() = html.clientTop + val clientWidth: Int + get() = html.clientWidth + val clientHeight: Int + get() = html.clientHeight + + val offsetLeft: Int + get() = html.offsetLeft + val offsetTop: Int + get() = html.offsetTop + val offsetWidth: Int + get() = html.offsetWidth + val offsetHeight: Int + get() = html.offsetHeight + + val offsetLeftTotal: Int + get() { + var element: HTMLElement? = html + var offset = 0 + while (element != null) { + offset += element.offsetLeft + element = element.offsetParent as? HTMLElement + } + return offset + } + val offsetTopTotal: Int + get() { + var element: HTMLElement? = html + var offset = 0 + while (element != null) { + offset += element.offsetTop + element = element.offsetParent as? HTMLElement + } + return offset + } + + val dimension: Dimension + get() = html.getBoundingClientRect().toDimension() + + var title by AttributeDelegate() + + val style = view.style + fun style(block: CSSStyleDeclaration.() -> Unit) { + block(style) + } + + fun focus() { + html.focus() + } + + fun blur() { + html.blur() + } + + fun click() { + html.click() + } + + val onClick = EventHandler() + val onDblClick = EventHandler() + val onContext = EventHandler() + + val onMouseDown = EventHandler() + val onMouseMove = EventHandler() + val onMouseUp = EventHandler() + val onMouseEnter = EventHandler() + val onMouseLeave = EventHandler() + + val onWheel = EventHandler() + + val onKeyDown = EventHandler() + val onKeyPress = EventHandler() + val onKeyUp = EventHandler() + + val onFocus = EventHandler() + val onBlur = EventHandler() + + init { + onClick.bind(view, "click") + onDblClick.bind(view, "dblclick") + onContext.bind(view, "contextmenu") + + onMouseDown.bind(view, "mousedown") + onMouseMove.bind(view, "mousemove") + onMouseUp.bind(view, "mouseup") + onMouseEnter.bind(view, "mouseenter") + onMouseLeave.bind(view, "mouseleave") + + onWheel.bind(view, "wheel") + + onKeyDown.bind(view, "keydown") + onKeyPress.bind(view, "keypress") + onKeyUp.bind(view, "keyup") + + onFocus.bind(view, "focus") + onBlur.bind(view, "blur") + } +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/ViewCollection.kt b/src/jsMain/kotlin/de/westermann/kwebview/ViewCollection.kt new file mode 100644 index 0000000..44e4d43 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/ViewCollection.kt @@ -0,0 +1,69 @@ +package de.westermann.kwebview + +import org.w3c.dom.HTMLElement +import kotlin.dom.clear + +/** + * @author lars + */ +abstract class ViewCollection(view: HTMLElement = createHtmlView()) : View(view), Iterable { + + private val children: MutableList = mutableListOf() + + fun append(view: V) { + children += view + html.appendChild(view.html) + } + + operator fun plusAssign(view: V) = append(view) + + fun prepand(view: V) { + children.add(0, view) + html.insertBefore(view.html, html.firstChild) + } + + fun remove(view: V) { + if (children.contains(view)) { + children -= view + html.removeChild(view.html) + } + } + + fun toForeground(view: V) { + if (view in children && children.indexOf(view) < children.size - 1) { + remove(view) + append(view) + } + } + + fun toBackground(view: V) { + if (view in children && children.indexOf(view) > 0) { + remove(view) + prepand(view) + } + } + + fun first(): V = children.first() + fun last(): V = children.last() + + operator fun minusAssign(view: V) = remove(view) + + val isEmpty: Boolean + get() = children.isEmpty() + + fun clear() { + children.clear() + html.clear() + } + + override fun iterator(): Iterator = children.iterator() + + val size: Int + get() = children.size + + operator fun contains(view: V) = children.contains(view) + + operator fun V.unaryPlus() { + append(this) + } +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/ViewForLabel.kt b/src/jsMain/kotlin/de/westermann/kwebview/ViewForLabel.kt new file mode 100644 index 0000000..88330e8 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/ViewForLabel.kt @@ -0,0 +1,57 @@ +package de.westermann.kwebview + +import de.westermann.kwebview.components.Label +import org.w3c.dom.HTMLInputElement +import kotlin.math.abs +import kotlin.random.Random + +abstract class ViewForLabel : View(createHtmlView()) { + override val html = super.html as HTMLInputElement + + private var label: Label? = null + + fun setLabel(label: Label) { + if (this.label != null) { + throw IllegalStateException("Label already set!") + } + + this.label = label + + val id = id + if (id?.isNotBlank() == true) { + label.html.htmlFor = id + } else { + val newId = this::class.simpleName?.toDashCase() + "-" + generateId() + this.id = newId + label.html.htmlFor = newId + } + } + + private var requiredInternal by AttributeDelegate("required") + var required: Boolean + get() = requiredInternal != null + set(value) { + requiredInternal = if (value) "required" else null + } + private var readonlyInternal by AttributeDelegate("readonly") + var readonly: Boolean + get() = readonlyInternal != null + set(value) { + readonlyInternal = if (value) "readonly" else null + } + + var tabindex by AttributeDelegate() + fun preventTabStop() { + tabindex = "-1" + } + + companion object { + fun generateId(length: Int = 16): String { + var str = "" + while (str.length <= length) { + str += abs(Random.nextLong()).toString(36) + } + return str.take(length) + } + } +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Body.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Body.kt new file mode 100644 index 0000000..766467d --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Body.kt @@ -0,0 +1,42 @@ +package de.westermann.kwebview.components + +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.i18n +import org.w3c.dom.DocumentReadyState +import org.w3c.dom.HTMLBodyElement +import org.w3c.dom.LOADING +import kotlin.browser.document +import kotlin.browser.window + +object Body : ViewCollection(document.body + ?: throw NullPointerException("Access to body before body was loaded")) { + override val html = super.html as HTMLBodyElement +} + +@KWebViewDsl +fun init(language: String? = null, block: Body.() -> Unit) { + var done = if (language == null) 1 else 2 + if (document.readyState == DocumentReadyState.LOADING) { + window.onload = { + done -= 1 + if (done <= 0) { + block(Body) + } + } + } else { + done -= 1 + if (done <= 0) { + block(Body) + } + } + if (language != null) { + i18n.load(language) { + done -= 1 + if (done <= 0) { + block(Body) + } + } + } +} \ No newline at end of file diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/BoxView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/BoxView.kt new file mode 100644 index 0000000..983a187 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/BoxView.kt @@ -0,0 +1,22 @@ +package de.westermann.kwebview.components + +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLDivElement + +class BoxView() : ViewCollection(createHtmlView()) { + override val html = super.html as HTMLDivElement +} + +@KWebViewDsl +fun ViewCollection.boxView(vararg classes: String, init: BoxView.() -> Unit = {}): BoxView { + val view = BoxView() + for (c in classes) { + view.classList += c + } + append(view) + init(view) + return view +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Button.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Button.kt new file mode 100644 index 0000000..5094ab9 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Button.kt @@ -0,0 +1,53 @@ +package de.westermann.kwebview.components + +import de.westermann.kobserve.Property +import de.westermann.kobserve.ReadOnlyProperty +import de.westermann.kobserve.basic.property +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLButtonElement + +/** + * Represents a html span element. + * + * @author lars + */ +class Button() : ViewCollection(createHtmlView()) { + + constructor(text: String) : this() { + this.text = text + } + + override val html = super.html as HTMLButtonElement + + fun bind(property: ReadOnlyProperty) { + textProperty.bind(property) + } + + fun unbind() { + textProperty.unbind() + } + + var text: String + get() = html.textContent ?: "" + set(value) { + html.textContent = value + textProperty.invalidate() + } + + val textProperty: Property = property(this::text) +} + +@KWebViewDsl +fun ViewCollection.button(text: String = "", init: Button.() -> Unit = {}) = + Button(text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.button(text: ReadOnlyProperty, init: Button.() -> Unit = {}) = + Button(text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.button(init: Button.() -> Unit = {}) = + Button().also(this::append).also(init) diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Checkbox.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Checkbox.kt new file mode 100644 index 0000000..a4aaf4f --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Checkbox.kt @@ -0,0 +1,68 @@ +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.kwebview.* +import org.w3c.dom.events.Event +import org.w3c.dom.events.EventListener + +class Checkbox( + initValue: Boolean = false +) : ViewForLabel() { + + fun bind(property: ReadOnlyProperty) { + checkedProperty.bind(property) + readonly = true + } + + fun bind(property: Property) { + checkedProperty.bindBidirectional(property) + } + + fun unbind() { + checkedProperty.unbind() + } + + var checked: Boolean + get() = html.checked + set(value) { + html.checked = value + checkedProperty.invalidate() + } + + val checkedProperty: Property = property(this::checked) + + init { + checked = initValue + html.type = "checkbox" + + var lastValue = checked + val changeListener = object : EventListener { + override fun handleEvent(event: Event) { + val value = checked + if (value != checkedProperty.value || value != lastValue) { + lastValue = value + checkedProperty.value = value + } + } + } + + html.addEventListener("change", changeListener) + html.addEventListener("keyup", changeListener) + html.addEventListener("keypress", changeListener) + } +} + +@KWebViewDsl +fun ViewCollection.checkbox(value: Boolean = false, init: Checkbox.() -> Unit = {}) = + Checkbox(value).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.checkbox(value: ReadOnlyProperty, init: Checkbox.() -> Unit = {}) = + Checkbox(value.value).also(this::append).also { it.bind(value) }.also(init) + +@KWebViewDsl +fun ViewCollection.checkbox(value: Property, init: Checkbox.() -> Unit = {}) = + Checkbox(value.value).also(this::append).also { it.bind(value) }.also(init) diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/FilterList.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/FilterList.kt new file mode 100644 index 0000000..3da687a --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/FilterList.kt @@ -0,0 +1,105 @@ +package de.westermann.kwebview.components + +import de.westermann.kobserve.Property +import de.westermann.kobserve.ReadOnlyProperty +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView + +class FilterList( + val property: ReadOnlyProperty, + val filter: Filter +) : ViewCollection(createHtmlView()) { + + private val content: MutableMap = mutableMapOf() + + fun update() { + val list = filter.filter(property.value) + var missing = list + + for ((element, view) in content.entries) { + if (element in list) { + missing -= element + } else { + if (contains(view)) { + remove(view) + } + if (!filter.useCache) { + content -= element + } + } + } + + for (element in missing) { + val view = filter.render(element) + append(view) + if (property is Property) { + view.onClick { + property.value = element + } + } + content[element] = view + } + + clear() + + for (element in list) { + append(content[element]!!) + } + } + + init { + update() + + property.onChange { + update() + } + } +} + +interface Filter { + fun filter(partial: T): List + fun render(element: T): V + + val useCache: Boolean +} + +class StringFilter( + private val dataSet: List +) : Filter { + override fun filter(partial: String): List { + val lower = partial.trim().toLowerCase() + return dataSet.filter { + it.toLowerCase().contains(lower) + }.sortedBy { it.length + it.toLowerCase().indexOf(partial) * 2 } + } + + override fun render(element: String) = TextView(element) + + override val useCache = true +} + +class StaticStringFilter( + private val dataSet: List +) : Filter { + override fun filter(partial: String) = dataSet + + override fun render(element: String) = TextView(element) + + override val useCache = true +} + + +class DynamicStringFilter( + private val filter: (partial: String) -> List +) : Filter { + override fun filter(partial: String) = filter.invoke(partial) + + override fun render(element: String) = TextView(element) + + override val useCache = false +} + + +fun ViewCollection>.filterList(property: ReadOnlyProperty, filter: Filter, init: FilterList.() -> Unit = {}) = + FilterList(property, filter).also(this::append).also(init) \ No newline at end of file diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Heading.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Heading.kt new file mode 100644 index 0000000..7808b0f --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Heading.kt @@ -0,0 +1,96 @@ +package de.westermann.kwebview.components + +import de.westermann.kobserve.Property +import de.westermann.kobserve.ReadOnlyProperty +import de.westermann.kobserve.basic.property +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLHeadingElement + +class Heading( + val type: Type, + value: String = "" +) : View(createHtmlView(type.tagName)) { + + override val html = super.html as HTMLHeadingElement + + fun bind(property: ReadOnlyProperty) { + textProperty.bind(property) + } + + fun unbind() { + textProperty.unbind() + } + + var text: String + get() = html.textContent ?: "" + set(value) { + html.textContent = value + textProperty.invalidate() + } + + val textProperty: Property = property(this::text) + + init { + text = value + } + + enum class Type(val tagName: String) { + H1("h1"), + H2("h2"), + H3("h3"), + H4("h4"), + H5("h5"), + H6("h6") + } +} + +@KWebViewDsl +fun ViewCollection.h1(text: String = "", init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H1, text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.h1(text: ReadOnlyProperty, init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H1, text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.h2(text: String = "", init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H2, text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.h2(text: ReadOnlyProperty, init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H2, text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.h3(text: String = "", init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H3, text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.h3(text: ReadOnlyProperty, init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H3, text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.h4(text: String = "", init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H4, text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.h4(text: ReadOnlyProperty, init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H4, text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.h5(text: String = "", init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H5, text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.h5(text: ReadOnlyProperty, init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H5, text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.h6(text: String = "", init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H6, text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.h6(text: ReadOnlyProperty, init: Heading.() -> Unit = {}) = + Heading(Heading.Type.H6, text.value).also(this::append).also { it.bind(text) }.also(init) diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/ImageView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/ImageView.kt new file mode 100644 index 0000000..c1c1f6b --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/ImageView.kt @@ -0,0 +1,46 @@ +package de.westermann.kwebview.components + +import de.westermann.kobserve.Property +import de.westermann.kobserve.ReadOnlyProperty +import de.westermann.kobserve.basic.property +import de.westermann.kwebview.* +import org.w3c.dom.HTMLImageElement + +class ImageView( + src: String +) : View(createHtmlView("img")) { + + override val html = super.html as HTMLImageElement + + fun bind(property: ReadOnlyProperty) { + sourceProperty.bind(property) + } + + fun unbind() { + sourceProperty.unbind() + } + + var source: String + get() = html.src + set(value) { + html.src = value + sourceProperty.invalidate() + } + + val sourceProperty: Property = property(this::source) + + + var alt by AttributeDelegate("alt") + + init { + source = src + } +} + +@KWebViewDsl +fun ViewCollection.imageView(src: String = "", init: ImageView.() -> Unit = {}) = + ImageView(src).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.imageView(src: ReadOnlyProperty, init: ImageView.() -> Unit = {}) = + ImageView(src.value).also(this::append).also { it.bind(src) }.also(init) diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/InputView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/InputView.kt new file mode 100644 index 0000000..b7c5685 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/InputView.kt @@ -0,0 +1,154 @@ +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.not +import de.westermann.kwebview.* +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.events.Event +import org.w3c.dom.events.EventListener +import org.w3c.dom.events.KeyboardEvent + +class InputView( + type: InputType, + initValue: String = "" +) : ViewForLabel() { + + fun bind(property: ReadOnlyProperty) { + valueProperty.bind(property) + readonly = true + } + + fun bind(property: Property) { + valueProperty.bindBidirectional(property) + } + + fun bind(property: ValidationProperty) { + valueProperty.bindBidirectional(property) + invalidProperty.bind(!property.validProperty) + } + + fun unbind() { + valueProperty.unbind() + if (invalidProperty.isBound) { + invalidProperty.unbind() + } + } + + var value: String + get() = html.value + set(value) { + html.value = value + valueProperty.invalidate() + } + + val valueProperty: Property = property(this::value) + + var placeholder: String + get() = html.placeholder + set(value) { + html.placeholder = value + placeholderProperty.invalidate() + } + + val placeholderProperty: Property = property(this::placeholder) + + val invalidProperty by ClassDelegate("invalid") + var invalid by invalidProperty + + private var typeInternal by AttributeDelegate("type") + var type: InputType? + get() = typeInternal?.let(InputType.Companion::find) + set(value) { + typeInternal = value?.html + } + private var minInternal by AttributeDelegate("min") + var min: Double? + get() = minInternal?.toDoubleOrNull() + set(value) { + minInternal = value?.toString() + } + private var maxInternal by AttributeDelegate("max") + var max: Double? + get() = maxInternal?.toDoubleOrNull() + set(value) { + maxInternal = value?.toString() + } + private var stepInternal by AttributeDelegate("step") + var step: Double? + get() = stepInternal?.toDoubleOrNull() + set(value) { + stepInternal = value?.toString() + } + + init { + value = initValue + this.type = type + + var lastValue = value + val changeListener = object : EventListener { + override fun handleEvent(event: Event) { + val value = value + if (value != valueProperty.value || value != lastValue) { + lastValue = value + valueProperty.value = value + } + + (event as? KeyboardEvent)?.let { e -> + when (e.keyCode) { + 13, 27 -> blur() + } + } + } + } + + html.addEventListener("change", changeListener) + html.addEventListener("keyup", changeListener) + html.addEventListener("keypress", changeListener) + } +} + +enum class InputType(val html: String) { + TEXT("text"), + NUMBER("number"), + PASSWORD("password"); + + companion object { + fun find(html: String): InputType? = values().find { it.html == html } + } +} + +@KWebViewDsl +fun ViewCollection.inputView(text: String = "", init: InputView.() -> Unit = {}) = + InputView(InputType.TEXT, text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.inputView(text: ReadOnlyProperty, init: InputView.() -> Unit = {}) = + InputView(InputType.TEXT, text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.inputView(text: Property, init: InputView.() -> Unit = {}) = + InputView(InputType.TEXT, text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.inputView(text: ValidationProperty, init: InputView.() -> Unit = {}) = + InputView(InputType.TEXT, text.value).also(this::append).also { it.bind(text) }.also(init) + + +@KWebViewDsl +fun ViewCollection.inputView(type: InputType = InputType.TEXT, text: String = "", init: InputView.() -> Unit = {}) = + InputView(type, text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.inputView(type: InputType = InputType.TEXT, text: ReadOnlyProperty, init: InputView.() -> Unit = {}) = + InputView(type, text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.inputView(type: InputType = InputType.TEXT, text: Property, init: InputView.() -> Unit = {}) = + InputView(type, text.value).also(this::append).also { it.bind(text) }.also(init) + +@KWebViewDsl +fun ViewCollection.inputView(type: InputType = InputType.TEXT, text: ValidationProperty, init: InputView.() -> Unit = {}) = + InputView(type, text.value).also(this::append).also { it.bind(text) }.also(init) diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Label.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Label.kt new file mode 100644 index 0000000..c2985ea --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Label.kt @@ -0,0 +1,51 @@ +package de.westermann.kwebview.components + +import de.westermann.kobserve.Property +import de.westermann.kobserve.ReadOnlyProperty +import de.westermann.kobserve.basic.property +import de.westermann.kwebview.* +import org.w3c.dom.HTMLLabelElement + +/** + * Represents a html label element. + * + * @author lars + */ +class Label( + inputElement: ViewForLabel, + value: String = "" +) : View(createHtmlView()) { + + override val html = super.html as HTMLLabelElement + + fun bind(property: ReadOnlyProperty) { + textProperty.bind(property) + } + + fun unbind() { + textProperty.unbind() + } + + var text: String + get() = html.textContent ?: "" + set(value) { + html.textContent = value + textProperty.invalidate() + } + + val textProperty: Property = property(this::text) + + init { + text = value + + inputElement.setLabel(this) + } +} + +@KWebViewDsl +fun ViewCollection.label(inputElement: ViewForLabel, text: String = "", init: Label.() -> Unit = {}) = + Label(inputElement, text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.label(inputElement: ViewForLabel, text: ReadOnlyProperty, init: Label.() -> Unit = {}) = + Label(inputElement, text.value).also(this::append).also { it.bind(text) }.also(init) diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Link.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Link.kt new file mode 100644 index 0000000..014a6c9 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Link.kt @@ -0,0 +1,44 @@ +package de.westermann.kwebview.components + +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLAnchorElement + +/** + * Represents a html span element. + * + * @author lars + */ +class Link(target: String) : ViewCollection(createHtmlView("a")) { + + override val html = super.html as HTMLAnchorElement + + var text: String? + get() = html.textContent + set(value) { + html.textContent = value + } + + var target: String + get() = html.href + set(value) { + html.href = value + } + + init { + this.target = target + } +} + +@KWebViewDsl +fun ViewCollection.link(target: String, text: String? = null, init: Link.() -> Unit = {}): Link { + val view = Link(target) + if (text != null) { + view.text = text + } + append(view) + init(view) + return view +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/OptionView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/OptionView.kt new file mode 100644 index 0000000..64a5bc1 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/OptionView.kt @@ -0,0 +1,32 @@ +package de.westermann.kwebview.components + +import de.westermann.kwebview.View +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLOptionElement + +class OptionView(val value: T) : View(createHtmlView()) { + + override val html = super.html as HTMLOptionElement + + var htmlValue: String + get() = html.value + set(value) { + html.value = value + } + + var text: String + get() = html.text + set(value) { + html.text = value + } + + val index: Int + get() = html.index + + var selected: Boolean + get() = html.selected + set(value) { + html.selected = value + } + +} \ No newline at end of file diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/SelectView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/SelectView.kt new file mode 100644 index 0000000..d1fa0f1 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/SelectView.kt @@ -0,0 +1,112 @@ +package de.westermann.kwebview.components + +import de.westermann.kobserve.Property +import de.westermann.kobserve.ReadOnlyProperty +import de.westermann.kobserve.basic.property +import de.westermann.kwebview.AttributeDelegate +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLSelectElement +import org.w3c.dom.events.Event +import org.w3c.dom.events.EventListener + +class SelectView( + dataSet: List, + private val initValue: T, + val transform: (T) -> String = { it.toString() } +) : ViewCollection>(createHtmlView()) { + + override val html = super.html as HTMLSelectElement + + fun bind(property: ReadOnlyProperty) { + valueProperty.bind(property) + readonly = true + } + + fun bind(property: Property) { + valueProperty.bindBidirectional(property) + } + + fun unbind() { + valueProperty.unbind() + } + + var dataSet: List = emptyList() + set(value) { + field = value + clear() + + value.forEachIndexed { index, v -> + +OptionView(v).also { option -> + option.text = transform(v) + option.htmlValue = index.toString() + } + } + } + + var index: Int + get() = html.selectedIndex + set(value) { + val invalidate = html.selectedIndex != value + html.selectedIndex = value + if (invalidate) { + valueProperty.invalidate() + } + } + + var value: T + get() = dataSet.getOrNull(index) ?: initValue + set(value) { + index = dataSet.indexOf(value) + } + val valueProperty = property(this::value) + + private var readonlyInternal by AttributeDelegate("readonly") + var readonly: Boolean + get() = readonlyInternal != null + set(value) { + readonlyInternal = if (value) "readonly" else null + } + + var tabindex by AttributeDelegate() + fun preventTabStop() { + tabindex = "-1" + } + + init { + this.dataSet = dataSet + this.value = initValue + + html.addEventListener("change", object : EventListener { + override fun handleEvent(event: Event) { + valueProperty.invalidate() + } + }) + } +} + +@KWebViewDsl +fun ViewCollection>.selectView(dataSet: List, initValue: T, transform: (T) -> String = { it.toString() }, init: SelectView.() -> Unit = {}) = + SelectView(dataSet, initValue, transform).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection>.selectView(dataSet: List, property: ReadOnlyProperty, transform: (T) -> String = { it.toString() }, init: SelectView.() -> Unit = {}) = + SelectView(dataSet, property.value, transform).apply { bind(property) }.also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection>.selectView(dataSet: List, property: Property, transform: (T) -> String = { it.toString() }, init: SelectView.() -> Unit = {}) = + SelectView(dataSet, property.value, transform).apply { bind(property) }.also(this::append).also(init) + + +@KWebViewDsl +inline fun > ViewCollection>.selectView(initValue: T, noinline transform: (T) -> String = { it.toString() }, init: SelectView.() -> Unit = {}) = + SelectView(enumValues().toList(), initValue, transform).also(this::append).also(init) + +@KWebViewDsl +inline fun > ViewCollection>.selectView(property: ReadOnlyProperty, noinline transform: (T) -> String = { it.toString() }, init: SelectView.() -> Unit = {}) = + SelectView(enumValues().toList(), property.value, transform).apply { bind(property) }.also(this::append).also(init) + +@KWebViewDsl +inline fun > ViewCollection>.selectView(property: Property, noinline transform: (T) -> String = { it.toString() }, init: SelectView.() -> Unit = {}) = + SelectView(enumValues().toList(), property.value, transform).apply { bind(property) }.also(this::append).also(init) diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/Table.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/Table.kt new file mode 100644 index 0000000..5346344 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/Table.kt @@ -0,0 +1,22 @@ +package de.westermann.kwebview.components + +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLTableElement + +class Table() : ViewCollection(createHtmlView()) { + override val html = super.html as HTMLTableElement +} + +@KWebViewDsl +fun ViewCollection.table(vararg classes: String, init: Table.() -> Unit = {}): Table { + val view = Table() + for (c in classes) { + view.classList += c + } + append(view) + init(view) + return view +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/TableCaption.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/TableCaption.kt new file mode 100644 index 0000000..ad3830f --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/TableCaption.kt @@ -0,0 +1,19 @@ +package de.westermann.kwebview.components + +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLTableCaptionElement + +class TableCaption() : ViewCollection(createHtmlView("caption")) { + override val html = super.html as HTMLTableCaptionElement +} + +@KWebViewDsl +fun ViewCollection.caption(init: TableCaption.() -> Unit = {}): TableCaption { + val view = TableCaption() + append(view) + init(view) + return view +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/TableCell.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/TableCell.kt new file mode 100644 index 0000000..de7c3df --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/TableCell.kt @@ -0,0 +1,41 @@ +package de.westermann.kwebview.components + +import de.westermann.kwebview.* +import org.w3c.dom.HTMLTableCellElement + +class TableCell(val isHead: Boolean) : + ViewCollection(createHtmlView(if (isHead) "th" else "td")) { + override val html = super.html as HTMLTableCellElement + + private var colSpanInternal by AttributeDelegate("colspan") + var colSpan: Int? + get() = colSpanInternal?.toIntOrNull() + set(value) { + colSpanInternal = value?.toString() + } + + private var rowSpanInternal by AttributeDelegate("rowspan") + var rowSpan: Int? + get() = rowSpanInternal?.toIntOrNull() + set(value) { + rowSpanInternal = value?.toString() + } +} + +@KWebViewDsl +fun ViewCollection.cell(colSpan: Int? = null, init: TableCell.() -> Unit = {}): TableCell { + val view = TableCell(false) + view.colSpan = colSpan + append(view) + init(view) + return view +} + +@KWebViewDsl +fun ViewCollection.head(colSpan: Int? = null, init: TableCell.() -> Unit = {}): TableCell { + val view = TableCell(true) + view.colSpan = colSpan + append(view) + init(view) + return view +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/TableRow.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/TableRow.kt new file mode 100644 index 0000000..8aab596 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/TableRow.kt @@ -0,0 +1,21 @@ +package de.westermann.kwebview.components + +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLTableRowElement + +class TableRow() : ViewCollection(createHtmlView("tr")) { + override val html = super.html as HTMLTableRowElement +} + +@KWebViewDsl +fun ViewCollection.row(vararg classes: String, init: TableRow.() -> Unit = {}): TableRow { + val view = TableRow() + for (c in classes) { + view.classList += c + } + append(view) + init(view) + return view +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/TableSection.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/TableSection.kt new file mode 100644 index 0000000..2bb9fbd --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/TableSection.kt @@ -0,0 +1,40 @@ +package de.westermann.kwebview.components + +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLTableSectionElement + +class TableSection(val type: Type) : ViewCollection(createHtmlView(type.tagName)) { + override val html = super.html as HTMLTableSectionElement + + enum class Type(val tagName: String) { + THEAD("thead"), + TBODY("tbody"), + TFOOT("tfoot") + } +} + +@KWebViewDsl +fun ViewCollection.thead(init: TableSection.() -> Unit = {}): TableSection { + val view = TableSection(TableSection.Type.THEAD) + append(view) + init(view) + return view +} + +@KWebViewDsl +fun ViewCollection.tbody(init: TableSection.() -> Unit = {}): TableSection { + val view = TableSection(TableSection.Type.TBODY) + append(view) + init(view) + return view +} + +@KWebViewDsl +fun ViewCollection.tfoot(init: TableSection.() -> Unit = {}): TableSection { + val view = TableSection(TableSection.Type.TFOOT) + append(view) + init(view) + return view +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/components/TextView.kt b/src/jsMain/kotlin/de/westermann/kwebview/components/TextView.kt new file mode 100644 index 0000000..9228091 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/components/TextView.kt @@ -0,0 +1,51 @@ +package de.westermann.kwebview.components + +import de.westermann.kobserve.Property +import de.westermann.kobserve.ReadOnlyProperty +import de.westermann.kobserve.basic.property +import de.westermann.kwebview.KWebViewDsl +import de.westermann.kwebview.View +import de.westermann.kwebview.ViewCollection +import de.westermann.kwebview.createHtmlView +import org.w3c.dom.HTMLSpanElement + +/** + * Represents a html span element. + * + * @author lars + */ +class TextView( + value: String = "" +) : View(createHtmlView()) { + + override val html = super.html as HTMLSpanElement + + fun bind(property: ReadOnlyProperty) { + textProperty.bind(property) + } + + fun unbind() { + textProperty.unbind() + } + + var text: String + get() = html.textContent ?: "" + set(value) { + html.textContent = value + textProperty.invalidate() + } + + val textProperty: Property = property(this::text) + + init { + text = value + } +} + +@KWebViewDsl +fun ViewCollection.textView(text: String = "", init: TextView.() -> Unit = {}) = + TextView(text).also(this::append).also(init) + +@KWebViewDsl +fun ViewCollection.textView(text: ReadOnlyProperty, init: TextView.() -> Unit = {}) = + TextView(text.value).also(this::append).also { it.bind(text) }.also(init) diff --git a/src/jsMain/kotlin/de/westermann/kwebview/extensions.kt b/src/jsMain/kotlin/de/westermann/kwebview/extensions.kt new file mode 100644 index 0000000..598b6bd --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/extensions.kt @@ -0,0 +1,79 @@ +package de.westermann.kwebview + +import de.westermann.kobserve.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 kotlin.browser.document +import kotlin.browser.window + +inline fun createHtmlView(tag: String? = null): V { + var tagName: String + if (tag != null) { + tagName = tag + } else { + tagName = V::class.js.name.toLowerCase().replace("html([a-z]*)element".toRegex(), "$1") + if (tagName.isBlank()) { + tagName = "div" + } + } + return document.createElement(tagName) as V +} + +fun String.toDashCase() = replace("([a-z])([A-Z])".toRegex(), "$1-$2").toLowerCase() + +inline fun EventHandler.bind(element: HTMLElement, event: String) { + val listener = object : EventListener { + override fun handleEvent(event: Event) { + this@bind.emit(event as T) + } + } + var isAttached = false + + val updateState = { + if (isEmpty() && isAttached) { + element.removeEventListener(event, listener) + isAttached = false + } else if (!isEmpty() && !isAttached) { + element.addEventListener(event, listener) + isAttached = true + } + } + + onAttach = updateState + onDetach = updateState + updateState() +} + +fun MouseEvent.toPoint(): Point = Point(clientX, clientY) +fun DOMRect.toDimension(): Dimension = Dimension(x, y, width, height) + +fun Number.format(digits: Int): String = this.asDynamic().toFixed(digits) + +external fun delete(p: dynamic): Boolean = definedExternally + +fun delete(thing: dynamic, key: String) { + delete(thing[key]) +} + +/** + * Apply current dom changes and recalculate all sizes. Executes the given block afterwards. + * + * @param timeout Optionally set a timeout for this call. Defaults to 1. + * @param block Callback + */ +fun async(timeout: Int = 1, block: () -> Unit) { + if (timeout < 1) throw IllegalArgumentException("Timeout must be greater than 0!") + window.setTimeout(block, timeout) +} + +fun interval(timeout: Int, block: () -> Unit): Int { + if (timeout < 1) throw IllegalArgumentException("Timeout must be greater than 0!") + return window.setInterval(block, timeout) +} + +fun clearInterval(id: Int) { + window.clearInterval(id) +} diff --git a/src/jsMain/kotlin/de/westermann/kwebview/i18n.kt b/src/jsMain/kotlin/de/westermann/kwebview/i18n.kt new file mode 100644 index 0000000..84b5b69 --- /dev/null +++ b/src/jsMain/kotlin/de/westermann/kwebview/i18n.kt @@ -0,0 +1,147 @@ +package de.westermann.kwebview + +import kotlin.browser.window + +@Suppress("ClassName") +object i18n { + private val data: MutableMap = mutableMapOf() + + private var fallbackLocale: Locale? = null + private var locale: Locale? = null + + fun register(id: String, name: String, path: String, fallback: Boolean = false) { + val locale = Locale(id, name, path, fallback) + + if (fallback) { + if (fallbackLocale != null) { + throw IllegalStateException("Fallback locale is already set!") + } + + fallbackLocale = locale + } + + data[id] = locale + + window.fetch(path).then { + it.json() + }.then { + locale.json = it + locale.isLoaded = true + }.catch { + throw it + } + } + + val isReady: Boolean + get() = data.values.all { it.isLoaded } + + fun load(id: String, block: () -> Unit) { + fun ready() { + if (isReady) { + locale = data[id] + block() + } else { + async(50) { ready() } + } + } + ready() + } + + private fun findKey(locale: Locale, key: String): dynamic { + val keys = key.split(".") + + var result = locale.json + for (k in keys) { + if (result.hasOwnProperty(k) as Boolean) { + result = result[k] + } else { + return undefined + } + } + + return result + } + + private fun findKey(key: String): dynamic { + var result: dynamic = undefined + + if (locale != null) { + result = findKey(locale!!, key) + } + + if (result == undefined) { + if (fallbackLocale != null) { + result = findKey(fallbackLocale!!, key) + } + } + + if (result == undefined) { + throw InternationalizationError("Cannot find key '$key'!") + } else { + return result + } + } + + private fun replace(str: String, arguments: List>): String { + val unnamed = arguments.filter { it.first == null }.map { it.second } + val named = arguments.mapNotNull { it.first?.to(it.second) } + + var s = str + + for ((key, replacement) in named) { + s = s.replace("{$key}", replacement?.toString() ?: "null") + } + + for (replacement in unnamed) { + if (s.contains("{}")) { + s = s.replaceFirst("{}", replacement?.toString() ?: "null") + } + } + + return s + } + + fun t(key: String, arguments: List>): String { + return replace(findKey(key).toString(), arguments) + } + + fun t(count: Number, key: String, arguments: List>): String { + val json = findKey(key) + if (count == 0 && json.hasOwnProperty("zero") as Boolean) { + return replace(json.zero.toString(), arguments) + } else if (count == 1 && json.hasOwnProperty("one") as Boolean) { + return replace(json.one.toString(), arguments) + } + + return if (json.hasOwnProperty("many") as Boolean) + replace(json.many.toString(), arguments) + else { + replace(json.toString(), arguments) + } + + } + + private class Locale( + val id: String, + val name: String, + val path: String, + val fallback: Boolean + ) { + var isLoaded = false + var json = js("{}") + } +} + +class InternationalizationError(message: String? = null) : Error(message) + +fun t(key: String) = i18n.t(key, emptyList()) + +fun t(key: String, vararg arguments: Any?) = i18n.t(key, arguments.map { null to it }) + +fun t(key: String, vararg arguments: Pair) = i18n.t(key, arguments.asList()) + +fun t(count: Number, key: String) = i18n.t(count, key, emptyList()) + +fun t(count: Number, key: String, vararg arguments: Any?) = i18n.t(count, key, arguments.map { null to it }) + +fun t(count: Number, key: String, vararg arguments: Pair) = i18n.t(count, key, arguments.asList()) diff --git a/src/jsMain/resources/external/font/Montserrat-Black.eot b/src/jsMain/resources/external/font/Montserrat-Black.eot new file mode 100644 index 0000000..3a5362d Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Black.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-Black.woff b/src/jsMain/resources/external/font/Montserrat-Black.woff new file mode 100644 index 0000000..5c60ad0 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Black.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-Black.woff2 b/src/jsMain/resources/external/font/Montserrat-Black.woff2 new file mode 100644 index 0000000..3ac8d46 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Black.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-BlackItalic.eot b/src/jsMain/resources/external/font/Montserrat-BlackItalic.eot new file mode 100644 index 0000000..4b92a25 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-BlackItalic.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-BlackItalic.woff b/src/jsMain/resources/external/font/Montserrat-BlackItalic.woff new file mode 100644 index 0000000..0caeeb5 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-BlackItalic.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-BlackItalic.woff2 b/src/jsMain/resources/external/font/Montserrat-BlackItalic.woff2 new file mode 100644 index 0000000..1e5d135 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-BlackItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-Bold.eot b/src/jsMain/resources/external/font/Montserrat-Bold.eot new file mode 100644 index 0000000..5ab4fd5 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Bold.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-Bold.woff b/src/jsMain/resources/external/font/Montserrat-Bold.woff new file mode 100644 index 0000000..aad827f Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Bold.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-Bold.woff2 b/src/jsMain/resources/external/font/Montserrat-Bold.woff2 new file mode 100644 index 0000000..ad25d26 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Bold.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-BoldItalic.eot b/src/jsMain/resources/external/font/Montserrat-BoldItalic.eot new file mode 100644 index 0000000..5eeff1e Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-BoldItalic.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-BoldItalic.woff b/src/jsMain/resources/external/font/Montserrat-BoldItalic.woff new file mode 100644 index 0000000..875f5b5 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-BoldItalic.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-BoldItalic.woff2 b/src/jsMain/resources/external/font/Montserrat-BoldItalic.woff2 new file mode 100644 index 0000000..f9a3d40 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-BoldItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraBold.eot b/src/jsMain/resources/external/font/Montserrat-ExtraBold.eot new file mode 100644 index 0000000..203ed8e Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraBold.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraBold.woff b/src/jsMain/resources/external/font/Montserrat-ExtraBold.woff new file mode 100644 index 0000000..08c7e2e Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraBold.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraBold.woff2 b/src/jsMain/resources/external/font/Montserrat-ExtraBold.woff2 new file mode 100644 index 0000000..e961597 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraBold.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraBoldItalic.eot b/src/jsMain/resources/external/font/Montserrat-ExtraBoldItalic.eot new file mode 100644 index 0000000..329171b Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraBoldItalic.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraBoldItalic.woff b/src/jsMain/resources/external/font/Montserrat-ExtraBoldItalic.woff new file mode 100644 index 0000000..935251b Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraBoldItalic.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraBoldItalic.woff2 b/src/jsMain/resources/external/font/Montserrat-ExtraBoldItalic.woff2 new file mode 100644 index 0000000..97b0e28 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraBoldItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraLight.eot b/src/jsMain/resources/external/font/Montserrat-ExtraLight.eot new file mode 100644 index 0000000..1a9012b Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraLight.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraLight.woff b/src/jsMain/resources/external/font/Montserrat-ExtraLight.woff new file mode 100644 index 0000000..a9a848e Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraLight.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraLight.woff2 b/src/jsMain/resources/external/font/Montserrat-ExtraLight.woff2 new file mode 100644 index 0000000..5fee343 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraLight.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraLightItalic.eot b/src/jsMain/resources/external/font/Montserrat-ExtraLightItalic.eot new file mode 100644 index 0000000..0c5369b Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraLightItalic.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraLightItalic.woff b/src/jsMain/resources/external/font/Montserrat-ExtraLightItalic.woff new file mode 100644 index 0000000..112038e Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraLightItalic.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-ExtraLightItalic.woff2 b/src/jsMain/resources/external/font/Montserrat-ExtraLightItalic.woff2 new file mode 100644 index 0000000..959479b Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ExtraLightItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-Italic.eot b/src/jsMain/resources/external/font/Montserrat-Italic.eot new file mode 100644 index 0000000..867a104 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Italic.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-Italic.woff b/src/jsMain/resources/external/font/Montserrat-Italic.woff new file mode 100644 index 0000000..1c15293 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Italic.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-Italic.woff2 b/src/jsMain/resources/external/font/Montserrat-Italic.woff2 new file mode 100644 index 0000000..8ccfb98 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Italic.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-Light.eot b/src/jsMain/resources/external/font/Montserrat-Light.eot new file mode 100644 index 0000000..62d678e Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Light.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-Light.woff b/src/jsMain/resources/external/font/Montserrat-Light.woff new file mode 100644 index 0000000..7aa52b2 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Light.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-Light.woff2 b/src/jsMain/resources/external/font/Montserrat-Light.woff2 new file mode 100644 index 0000000..0b253c4 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Light.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-LightItalic.eot b/src/jsMain/resources/external/font/Montserrat-LightItalic.eot new file mode 100644 index 0000000..9e70f97 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-LightItalic.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-LightItalic.woff b/src/jsMain/resources/external/font/Montserrat-LightItalic.woff new file mode 100644 index 0000000..ef12fe2 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-LightItalic.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-LightItalic.woff2 b/src/jsMain/resources/external/font/Montserrat-LightItalic.woff2 new file mode 100644 index 0000000..c4cc5a3 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-LightItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-Medium.eot b/src/jsMain/resources/external/font/Montserrat-Medium.eot new file mode 100644 index 0000000..abb0a84 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Medium.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-Medium.woff b/src/jsMain/resources/external/font/Montserrat-Medium.woff new file mode 100644 index 0000000..2778c8b Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Medium.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-Medium.woff2 b/src/jsMain/resources/external/font/Montserrat-Medium.woff2 new file mode 100644 index 0000000..80d6f58 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Medium.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-MediumItalic.eot b/src/jsMain/resources/external/font/Montserrat-MediumItalic.eot new file mode 100644 index 0000000..d7986e9 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-MediumItalic.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-MediumItalic.woff b/src/jsMain/resources/external/font/Montserrat-MediumItalic.woff new file mode 100644 index 0000000..7a2329f Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-MediumItalic.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-MediumItalic.woff2 b/src/jsMain/resources/external/font/Montserrat-MediumItalic.woff2 new file mode 100644 index 0000000..a7f9cfd Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-MediumItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-Regular.eot b/src/jsMain/resources/external/font/Montserrat-Regular.eot new file mode 100644 index 0000000..d030e7f Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Regular.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-Regular.woff b/src/jsMain/resources/external/font/Montserrat-Regular.woff new file mode 100644 index 0000000..ebb48a9 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Regular.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-Regular.woff2 b/src/jsMain/resources/external/font/Montserrat-Regular.woff2 new file mode 100644 index 0000000..3261a6a Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Regular.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-SemiBold.eot b/src/jsMain/resources/external/font/Montserrat-SemiBold.eot new file mode 100644 index 0000000..92bdd9b Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-SemiBold.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-SemiBold.woff b/src/jsMain/resources/external/font/Montserrat-SemiBold.woff new file mode 100644 index 0000000..32904f5 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-SemiBold.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-SemiBold.woff2 b/src/jsMain/resources/external/font/Montserrat-SemiBold.woff2 new file mode 100644 index 0000000..0c9bc28 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-SemiBold.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-SemiBoldItalic.eot b/src/jsMain/resources/external/font/Montserrat-SemiBoldItalic.eot new file mode 100644 index 0000000..9caec26 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-SemiBoldItalic.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-SemiBoldItalic.woff b/src/jsMain/resources/external/font/Montserrat-SemiBoldItalic.woff new file mode 100644 index 0000000..d990b52 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-SemiBoldItalic.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-SemiBoldItalic.woff2 b/src/jsMain/resources/external/font/Montserrat-SemiBoldItalic.woff2 new file mode 100644 index 0000000..4d597b3 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-SemiBoldItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-Thin.eot b/src/jsMain/resources/external/font/Montserrat-Thin.eot new file mode 100644 index 0000000..125b165 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Thin.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-Thin.woff b/src/jsMain/resources/external/font/Montserrat-Thin.woff new file mode 100644 index 0000000..43cffac Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Thin.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-Thin.woff2 b/src/jsMain/resources/external/font/Montserrat-Thin.woff2 new file mode 100644 index 0000000..ef8d0d5 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-Thin.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat-ThinItalic.eot b/src/jsMain/resources/external/font/Montserrat-ThinItalic.eot new file mode 100644 index 0000000..a53bd66 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ThinItalic.eot differ diff --git a/src/jsMain/resources/external/font/Montserrat-ThinItalic.woff b/src/jsMain/resources/external/font/Montserrat-ThinItalic.woff new file mode 100644 index 0000000..dd4a314 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ThinItalic.woff differ diff --git a/src/jsMain/resources/external/font/Montserrat-ThinItalic.woff2 b/src/jsMain/resources/external/font/Montserrat-ThinItalic.woff2 new file mode 100644 index 0000000..e5e9367 Binary files /dev/null and b/src/jsMain/resources/external/font/Montserrat-ThinItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/Montserrat.css b/src/jsMain/resources/external/font/Montserrat.css new file mode 100644 index 0000000..a52ef06 --- /dev/null +++ b/src/jsMain/resources/external/font/Montserrat.css @@ -0,0 +1,399 @@ +/** =================== MONTSERRAT =================== **/ + +/** Montserrat Thin **/ +@font-face { + font-family: "Montserrat"; + font-weight: 100; + font-style: normal; + src: url("Montserrat-Thin.eot"); + src: url("Montserrat-Thin.eot?#iefix") format('embedded-opentype'), + url("Montserrat-Thin.woff2") format("woff2"), + url("Montserrat-Thin.woff") format("woff"); +} + +/** Montserrat Thin-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 100; + font-style: italic; + src: url("Montserrat-ThinItalic.eot"); + src: url("Montserrat-ThinItalic.eot?#iefix") format('embedded-opentype'), + url("Montserrat-ThinItalic.woff2") format("woff2"), + url("Montserrat-ThinItalic.woff") format("woff"); +} + +/** Montserrat ExtraLight **/ +@font-face { + font-family: "Montserrat"; + font-weight: 200; + font-style: normal; + src: url("Montserrat-ExtraLight.eot"); + src: url("Montserrat-ExtraLight.eot?#iefix") format('embedded-opentype'), + url("Montserrat-ExtraLight.woff2") format("woff2"), + url("Montserrat-ExtraLight.woff") format("woff"); +} + +/** Montserrat ExtraLight-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 200; + font-style: italic; + src: url("Montserrat-ExtraLightItalic.eot"); + src: url("Montserrat-ExtraLightItalic.eot?#iefix") format('embedded-opentype'), + url("Montserrat-ExtraLightItalic.woff2") format("woff2"), + url("Montserrat-ExtraLightItalic.woff") format("woff"); +} + +/** Montserrat Light **/ +@font-face { + font-family: "Montserrat"; + font-weight: 300; + font-style: normal; + src: url("Montserrat-Light.eot"); + src: url("Montserrat-Light.eot?#iefix") format('embedded-opentype'), + url("Montserrat-Light.woff2") format("woff2"), + url("Montserrat-Light.woff") format("woff"); +} + +/** Montserrat Light-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 300; + font-style: italic; + src: url("Montserrat-LightItalic.eot"); + src: url("Montserrat-LightItalic.eot?#iefix") format('embedded-opentype'), + url("Montserrat-LightItalic.woff2") format("woff2"), + url("Montserrat-LightItalic.woff") format("woff"); +} + +/** Montserrat Regular **/ +@font-face { + font-family: "Montserrat"; + font-weight: 400; + font-style: normal; + src: url("Montserrat-Regular.eot"); + src: url("Montserrat-Regular.eot?#iefix") format('embedded-opentype'), + url("Montserrat-Regular.woff2") format("woff2"), + url("Montserrat-Regular.woff") format("woff"); +} + +/** Montserrat Regular-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 400; + font-style: italic; + src: url("Montserrat-Italic.eot"); + src: url("Montserrat-Italic.eot?#iefix") format('embedded-opentype'), + url("Montserrat-Italic.woff2") format("woff2"), + url("Montserrat-Italic.woff") format("woff"); +} + +/** Montserrat Medium **/ +@font-face { + font-family: "Montserrat"; + font-weight: 500; + font-style: normal; + src: url("Montserrat-Medium.eot"); + src: url("Montserrat-Medium.eot?#iefix") format('embedded-opentype'), + url("Montserrat-Medium.woff2") format("woff2"), + url("Montserrat-Medium.woff") format("woff"); +} + +/** Montserrat Medium-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 500; + font-style: italic; + src: url("Montserrat-MediumItalic.eot"); + src: url("Montserrat-MediumItalic.eot?#iefix") format('embedded-opentype'), + url("Montserrat-MediumItalic.woff2") format("woff2"), + url("Montserrat-MediumItalic.woff") format("woff"); +} + +/** Montserrat SemiBold **/ +@font-face { + font-family: "Montserrat"; + font-weight: 600; + font-style: normal; + src: url("Montserrat-SemiBold.eot"); + src: url("Montserrat-SemiBold.eot?#iefix") format('embedded-opentype'), + url("Montserrat-SemiBold.woff2") format("woff2"), + url("Montserrat-SemiBold.woff") format("woff"); +} + +/** Montserrat SemiBold-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 600; + font-style: italic; + src: url("Montserrat-SemiBoldItalic.eot"); + src: url("Montserrat-SemiBoldItalic.eot?#iefix") format('embedded-opentype'), + url("Montserrat-SemiBoldItalic.woff2") format("woff2"), + url("Montserrat-SemiBoldItalic.woff") format("woff"); +} + +/** Montserrat Bold **/ +@font-face { + font-family: "Montserrat"; + font-weight: 700; + font-style: normal; + src: url("Montserrat-Bold.eot"); + src: url("Montserrat-Bold.eot?#iefix") format('embedded-opentype'), + url("Montserrat-Bold.woff2") format("woff2"), + url("Montserrat-Bold.woff") format("woff"); +} + +/** Montserrat Bold-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 700; + font-style: italic; + src: url("Montserrat-BoldItalic.eot"); + src: url("Montserrat-BoldItalic.eot?#iefix") format('embedded-opentype'), + url("Montserrat-BoldItalic.woff2") format("woff2"), + url("Montserrat-BoldItalic.woff") format("woff"); +} + +/** Montserrat ExtraBold **/ +@font-face { + font-family: "Montserrat"; + font-weight: 800; + font-style: normal; + src: url("Montserrat-ExtraBold.eot"); + src: url("Montserrat-ExtraBold.eot?#iefix") format('embedded-opentype'), + url("Montserrat-ExtraBold.woff2") format("woff2"), + url("Montserrat-ExtraBold.woff") format("woff"); +} + +/** Montserrat ExtraBold-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 800; + font-style: italic; + src: url("Montserrat-ExtraBoldItalic.eot"); + src: url("Montserrat-ExtraBoldItalic.eot?#iefix") format('embedded-opentype'), + url("Montserrat-ExtraBoldItalic.woff2") format("woff2"), + url("Montserrat-ExtraBoldItalic.woff") format("woff"); +} + +/** Montserrat Black **/ +@font-face { + font-family: "Montserrat"; + font-weight: 900; + font-style: normal; + src: url("Montserrat-Black.eot"); + src: url("Montserrat-Black.eot?#iefix") format('embedded-opentype'), + url("Montserrat-Black.woff2") format("woff2"), + url("Montserrat-Black.woff") format("woff"); +} + +/** Montserrat Black-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 900; + font-style: italic; + src: url("Montserrat-BlackItalic.eot"); + src: url("Montserrat-BlackItalic.eot?#iefix") format('embedded-opentype'), + url("Montserrat-BlackItalic.woff2") format("woff2"), + url("Montserrat-BlackItalic.woff") format("woff"); +} + +/** =================== MONTSERRAT ALTERNATES =================== **/ + +/** Montserrat Alternates Thin **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 100; + font-style: normal; + src: url("MontserratAlternates-Thin.eot"); + src: url("MontserratAlternates-Thin.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-Thin.woff2") format("woff2"), + url("MontserratAlternates-Thin.woff") format("woff"); +} + +/** Montserrat Alternates Thin-Italic **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 100; + font-style: italic; + src: url("MontserratAlternates-ThinItalic.eot"); + src: url("MontserratAlternates-ThinItalic.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-ThinItalic.woff2") format("woff2"), + url("MontserratAlternates-ThinItalic.woff") format("woff"); +} + +/** Montserrat Alternates ExtraLight **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 200; + font-style: normal; + src: url("MontserratAlternates-ExtraLight.eot"); + src: url("MontserratAlternates-ExtraLight.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-ExtraLight.woff2") format("woff2"), + url("MontserratAlternates-ExtraLight.woff") format("woff"); +} + +/** Montserrat Alternates ExtraLight-Italic **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 200; + font-style: italic; + src: url("MontserratAlternates-ExtraLightItalic.eot"); + src: url("MontserratAlternates-ExtraLightItalic.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-ExtraLightItalic.woff2") format("woff2"), + url("MontserratAlternates-ExtraLightItalic.woff") format("woff"); +} + +/** Montserrat Alternates Light **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 300; + font-style: normal; + src: url("MontserratAlternates-Light.eot"); + src: url("MontserratAlternates-Light.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-Light.woff2") format("woff2"), + url("MontserratAlternates-Light.woff") format("woff"); +} + +/** Montserrat Alternates Light-Italic **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 300; + font-style: italic; + src: url("MontserratAlternates-LightItalic.eot"); + src: url("MontserratAlternates-LightItalic.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-LightItalic.woff2") format("woff2"), + url("MontserratAlternates-LightItalic.woff") format("woff"); +} + +/** Montserrat Alternates Regular **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 400; + font-style: normal; + src: url("MontserratAlternates-Regular.eot"); + src: url("MontserratAlternates-Regular.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-Regular.woff2") format("woff2"), + url("MontserratAlternates-Regular.woff") format("woff"); +} + +/** Montserrat Alternates Regular-Italic **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 400; + font-style: italic; + src: url("MontserratAlternates-Italic.eot"); + src: url("MontserratAlternates-Italic.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-Italic.woff2") format("woff2"), + url("MontserratAlternates-Italic.woff") format("woff"); +} + +/** Montserrat Alternates Medium **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 500; + font-style: normal; + src: url("MontserratAlternates-Medium.eot"); + src: url("MontserratAlternates-Medium.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-Medium.woff2") format("woff2"), + url("MontserratAlternates-Medium.woff") format("woff"); +} + +/** Montserrat Alternates Medium-Italic **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 500; + font-style: italic; + src: url("MontserratAlternates-MediumItalic.eot"); + src: url("MontserratAlternates-MediumItalic.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-MediumItalic.woff2") format("woff2"), + url("MontserratAlternates-MediumItalic.woff") format("woff"); +} + +/** Montserrat Alternates SemiBold **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 600; + font-style: normal; + src: url("MontserratAlternates-SemiBold.eot"); + src: url("MontserratAlternates-SemiBold.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-SemiBold.woff2") format("woff2"), + url("MontserratAlternates-SemiBold.woff") format("woff"); +} + +/** Montserrat Alternates SemiBold-Italic **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 600; + font-style: italic; + src: url("MontserratAlternates-SemiBoldItalic.eot"); + src: url("MontserratAlternates-SemiBoldItalic.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-SemiBoldItalic.woff2") format("woff2"), + url("MontserratAlternates-SemiBoldItalic.woff") format("woff"); +} + +/** Montserrat Alternates Bold **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 700; + font-style: normal; + src: url("MontserratAlternates-Bold.eot"); + src: url("MontserratAlternates-Bold.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-Bold.woff2") format("woff2"), + url("MontserratAlternates-Bold.woff") format("woff"); +} + +/** Montserrat Alternates Bold-Italic **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 700; + font-style: italic; + src: url("MontserratAlternates-BoldItalic.eot"); + src: url("MontserratAlternates-BoldItalic.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-BoldItalic.woff2") format("woff2"), + url("MontserratAlternates-BoldItalic.woff") format("woff"); +} + +/** Montserrat Alternates ExtraBold **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 800; + font-style: normal; + src: url("MontserratAlternates-ExtraBold.eot"); + src: url("MontserratAlternates-ExtraBold.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-ExtraBold.woff2") format("woff2"), + url("MontserratAlternates-ExtraBold.woff") format("woff"); +} + +/** Montserrat Alternates ExtraBold-Italic **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 800; + font-style: italic; + src: url("MontserratAlternates-ExtraBoldItalic.eot"); + src: url("MontserratAlternates-ExtraBoldItalic.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-ExtraBoldItalic.woff2") format("woff2"), + url("MontserratAlternates-ExtraBoldItalic.woff") format("woff"); +} + +/** Montserrat Alternates Black **/ +@font-face { + font-family: "Montserrat Alternates"; + font-weight: 900; + font-style: normal; + src: url("MontserratAlternates-Black.eot"); + src: url("MontserratAlternates-Black.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-Black.woff2") format("woff2"), + url("MontserratAlternates-Black.woff") format("woff"); +} + +/** Montserrat Alternates Black-Italic **/ +@font-face { + font-family: "Montserrat"; + font-weight: 900; + font-style: italic; + src: url("MontserratAlternates-BlackItalic.eot"); + src: url("MontserratAlternates-BlackItalic.eot?#iefix") format('embedded-opentype'), + url("MontserratAlternates-BlackItalic.woff2") format("woff2"), + url("MontserratAlternates-BlackItalic.woff") format("woff"); +} \ No newline at end of file diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Black.eot b/src/jsMain/resources/external/font/MontserratAlternates-Black.eot new file mode 100644 index 0000000..fc23ac4 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Black.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Black.woff b/src/jsMain/resources/external/font/MontserratAlternates-Black.woff new file mode 100644 index 0000000..11a3ce6 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Black.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Black.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-Black.woff2 new file mode 100644 index 0000000..235a3c8 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Black.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-BlackItalic.eot b/src/jsMain/resources/external/font/MontserratAlternates-BlackItalic.eot new file mode 100644 index 0000000..a965308 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-BlackItalic.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-BlackItalic.woff b/src/jsMain/resources/external/font/MontserratAlternates-BlackItalic.woff new file mode 100644 index 0000000..a2ddfd3 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-BlackItalic.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-BlackItalic.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-BlackItalic.woff2 new file mode 100644 index 0000000..a8004a1 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-BlackItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Bold.eot b/src/jsMain/resources/external/font/MontserratAlternates-Bold.eot new file mode 100644 index 0000000..f17c416 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Bold.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Bold.woff b/src/jsMain/resources/external/font/MontserratAlternates-Bold.woff new file mode 100644 index 0000000..9fc7e5e Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Bold.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Bold.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-Bold.woff2 new file mode 100644 index 0000000..22ff691 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Bold.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-BoldItalic.eot b/src/jsMain/resources/external/font/MontserratAlternates-BoldItalic.eot new file mode 100644 index 0000000..90cf559 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-BoldItalic.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-BoldItalic.woff b/src/jsMain/resources/external/font/MontserratAlternates-BoldItalic.woff new file mode 100644 index 0000000..e1483bd Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-BoldItalic.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-BoldItalic.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-BoldItalic.woff2 new file mode 100644 index 0000000..bed052a Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-BoldItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraBold.eot b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBold.eot new file mode 100644 index 0000000..3b1d443 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBold.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraBold.woff b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBold.woff new file mode 100644 index 0000000..f6a794d Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBold.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraBold.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBold.woff2 new file mode 100644 index 0000000..e2cd944 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBold.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraBoldItalic.eot b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBoldItalic.eot new file mode 100644 index 0000000..9af7c24 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBoldItalic.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraBoldItalic.woff b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBoldItalic.woff new file mode 100644 index 0000000..1403e27 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBoldItalic.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraBoldItalic.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBoldItalic.woff2 new file mode 100644 index 0000000..979839f Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraBoldItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraLight.eot b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLight.eot new file mode 100644 index 0000000..345c60d Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLight.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraLight.woff b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLight.woff new file mode 100644 index 0000000..6c47d58 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLight.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraLight.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLight.woff2 new file mode 100644 index 0000000..9fe8bb2 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLight.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraLightItalic.eot b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLightItalic.eot new file mode 100644 index 0000000..29b4c1f Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLightItalic.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraLightItalic.woff b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLightItalic.woff new file mode 100644 index 0000000..622cc9a Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLightItalic.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ExtraLightItalic.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLightItalic.woff2 new file mode 100644 index 0000000..2632c08 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ExtraLightItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Italic.eot b/src/jsMain/resources/external/font/MontserratAlternates-Italic.eot new file mode 100644 index 0000000..6e8c22e Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Italic.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Italic.woff b/src/jsMain/resources/external/font/MontserratAlternates-Italic.woff new file mode 100644 index 0000000..65094d4 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Italic.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Italic.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-Italic.woff2 new file mode 100644 index 0000000..40944d4 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Italic.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Light.eot b/src/jsMain/resources/external/font/MontserratAlternates-Light.eot new file mode 100644 index 0000000..e999003 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Light.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Light.woff b/src/jsMain/resources/external/font/MontserratAlternates-Light.woff new file mode 100644 index 0000000..4a9a0d4 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Light.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Light.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-Light.woff2 new file mode 100644 index 0000000..c7ef715 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Light.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-LightItalic.eot b/src/jsMain/resources/external/font/MontserratAlternates-LightItalic.eot new file mode 100644 index 0000000..fd1b318 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-LightItalic.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-LightItalic.woff b/src/jsMain/resources/external/font/MontserratAlternates-LightItalic.woff new file mode 100644 index 0000000..22f432f Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-LightItalic.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-LightItalic.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-LightItalic.woff2 new file mode 100644 index 0000000..9f3b5d0 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-LightItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Medium.eot b/src/jsMain/resources/external/font/MontserratAlternates-Medium.eot new file mode 100644 index 0000000..4dd10f2 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Medium.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Medium.woff b/src/jsMain/resources/external/font/MontserratAlternates-Medium.woff new file mode 100644 index 0000000..92bd129 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Medium.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Medium.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-Medium.woff2 new file mode 100644 index 0000000..f8f0530 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Medium.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-MediumItalic.eot b/src/jsMain/resources/external/font/MontserratAlternates-MediumItalic.eot new file mode 100644 index 0000000..9f33af7 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-MediumItalic.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-MediumItalic.woff b/src/jsMain/resources/external/font/MontserratAlternates-MediumItalic.woff new file mode 100644 index 0000000..aefdeee Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-MediumItalic.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-MediumItalic.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-MediumItalic.woff2 new file mode 100644 index 0000000..9775f42 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-MediumItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Regular.eot b/src/jsMain/resources/external/font/MontserratAlternates-Regular.eot new file mode 100644 index 0000000..ad10b6b Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Regular.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Regular.woff b/src/jsMain/resources/external/font/MontserratAlternates-Regular.woff new file mode 100644 index 0000000..3aaf1f7 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Regular.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Regular.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-Regular.woff2 new file mode 100644 index 0000000..f7d23ca Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Regular.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-SemiBold.eot b/src/jsMain/resources/external/font/MontserratAlternates-SemiBold.eot new file mode 100644 index 0000000..13a121d Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-SemiBold.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-SemiBold.woff b/src/jsMain/resources/external/font/MontserratAlternates-SemiBold.woff new file mode 100644 index 0000000..35ba984 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-SemiBold.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-SemiBold.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-SemiBold.woff2 new file mode 100644 index 0000000..80bf4c2 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-SemiBold.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-SemiBoldItalic.eot b/src/jsMain/resources/external/font/MontserratAlternates-SemiBoldItalic.eot new file mode 100644 index 0000000..799cae5 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-SemiBoldItalic.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-SemiBoldItalic.woff b/src/jsMain/resources/external/font/MontserratAlternates-SemiBoldItalic.woff new file mode 100644 index 0000000..39283e1 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-SemiBoldItalic.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-SemiBoldItalic.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-SemiBoldItalic.woff2 new file mode 100644 index 0000000..0bb26ae Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-SemiBoldItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Thin.eot b/src/jsMain/resources/external/font/MontserratAlternates-Thin.eot new file mode 100644 index 0000000..d49ba52 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Thin.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Thin.woff b/src/jsMain/resources/external/font/MontserratAlternates-Thin.woff new file mode 100644 index 0000000..9f031c2 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Thin.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-Thin.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-Thin.woff2 new file mode 100644 index 0000000..23a80f9 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-Thin.woff2 differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ThinItalic.eot b/src/jsMain/resources/external/font/MontserratAlternates-ThinItalic.eot new file mode 100644 index 0000000..2fcd16d Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ThinItalic.eot differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ThinItalic.woff b/src/jsMain/resources/external/font/MontserratAlternates-ThinItalic.woff new file mode 100644 index 0000000..c601966 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ThinItalic.woff differ diff --git a/src/jsMain/resources/external/font/MontserratAlternates-ThinItalic.woff2 b/src/jsMain/resources/external/font/MontserratAlternates-ThinItalic.woff2 new file mode 100644 index 0000000..d98a952 Binary files /dev/null and b/src/jsMain/resources/external/font/MontserratAlternates-ThinItalic.woff2 differ diff --git a/src/jsMain/resources/external/font/README.MD b/src/jsMain/resources/external/font/README.MD new file mode 100644 index 0000000..811ad40 --- /dev/null +++ b/src/jsMain/resources/external/font/README.MD @@ -0,0 +1,35 @@ +# The Montserrat Font Project +To use this font as a webfont, ```Montserrat.css``` is included. + +## How to use +### 1. @import +You can import the file into your stylesheet as follows: +```css +@import url("static/fonts/Montserrat/fonts/webfonts/Montserrat.css"); +``` + +**NOTE:** The directory where the stylesheet is placed. + +Then we can use it to style elements: +```css +body { + font-family: 'Montserrat', sans-serif; + font-weight: 400; +} +``` + +### 2. \ing a stylesheet +Similarly, you could link to the same asset as you would any other CSS filter, in the \ of the HTML document rather than in the CSS: +```html + +``` + +**NOTE:** The directory where the stylesheet is placed. + +Then we can use it to style elements: +```css +body { + font-family: 'Montserrat', sans-serif; + font-weight: 400; +} +``` \ No newline at end of file diff --git a/src/jsMain/resources/external/material-icons.css b/src/jsMain/resources/external/material-icons.css new file mode 100644 index 0000000..c134ba5 --- /dev/null +++ b/src/jsMain/resources/external/material-icons.css @@ -0,0 +1,24 @@ +/* fallback */ +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: local('Material Icons'), local('MaterialIcons-Regular'), url(material-icons.woff2) format('woff2'); +} + +.material-icons { + font-family: 'Material Icons' !important; + font-weight: normal; + font-style: normal; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -moz-font-feature-settings: 'liga'; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} diff --git a/src/jsMain/resources/external/material-icons.woff2 b/src/jsMain/resources/external/material-icons.woff2 new file mode 100644 index 0000000..e916217 Binary files /dev/null and b/src/jsMain/resources/external/material-icons.woff2 differ diff --git a/src/jsMain/resources/require.min.js b/src/jsMain/resources/require.min.js new file mode 100644 index 0000000..0dcbf0e --- /dev/null +++ b/src/jsMain/resources/require.min.js @@ -0,0 +1,2 @@ + +var requirejs,require,define;!function(global,setTimeout){function commentReplace(e,t){return t||""}function isFunction(e){return"[object Function]"===ostring.call(e)}function isArray(e){return"[object Array]"===ostring.call(e)}function each(e,t){if(e){var i;for(i=0;i-1&&(!e[i]||!t(e[i],i,e));i-=1);}}function hasProp(e,t){return hasOwn.call(e,t)}function getOwn(e,t){return hasProp(e,t)&&e[t]}function eachProp(e,t){var i;for(i in e)if(hasProp(e,i)&&t(e[i],i))break}function mixin(e,t,i,r){return t&&eachProp(t,function(t,n){!i&&hasProp(e,n)||(!r||"object"!=typeof t||!t||isArray(t)||isFunction(t)||t instanceof RegExp?e[n]=t:(e[n]||(e[n]={}),mixin(e[n],t,i,r)))}),e}function bind(e,t){return function(){return t.apply(e,arguments)}}function scripts(){return document.getElementsByTagName("script")}function defaultOnError(e){throw e}function getGlobal(e){if(!e)return e;var t=global;return each(e.split("."),function(e){t=t[e]}),t}function makeError(e,t,i,r){var n=new Error(t+"\nhttp://requirejs.org/docs/errors.html#"+e);return n.requireType=e,n.requireModules=r,i&&(n.originalError=i),n}function newContext(e){function t(e){var t,i;for(t=0;t0&&(e.splice(t-1,2),t-=2)}}function i(e,i,r){var n,o,a,s,u,c,d,p,f,l,h=i&&i.split("/"),m=y.map,g=m&&m["*"];if(e&&(c=(e=e.split("/")).length-1,y.nodeIdCompat&&jsSuffixRegExp.test(e[c])&&(e[c]=e[c].replace(jsSuffixRegExp,"")),"."===e[0].charAt(0)&&h&&(e=h.slice(0,h.length-1).concat(e)),t(e),e=e.join("/")),r&&m&&(h||g)){e:for(a=(o=e.split("/")).length;a>0;a-=1){if(u=o.slice(0,a).join("/"),h)for(s=h.length;s>0;s-=1)if((n=getOwn(m,h.slice(0,s).join("/")))&&(n=getOwn(n,u))){d=n,p=a;break e}!f&&g&&getOwn(g,u)&&(f=getOwn(g,u),l=a)}!d&&f&&(d=f,p=l),d&&(o.splice(0,p,d),e=o.join("/"))}return getOwn(y.pkgs,e)||e}function r(e){isBrowser&&each(scripts(),function(t){if(t.getAttribute("data-requiremodule")===e&&t.getAttribute("data-requirecontext")===q.contextName)return t.parentNode.removeChild(t),!0})}function n(e){var t=getOwn(y.paths,e);if(t&&isArray(t)&&t.length>1)return t.shift(),q.require.undef(e),q.makeRequire(null,{skipMap:!0})([e]),!0}function o(e){var t,i=e?e.indexOf("!"):-1;return i>-1&&(t=e.substring(0,i),e=e.substring(i+1,e.length)),[t,e]}function a(e,t,r,n){var a,s,u,c,d=null,p=t?t.name:null,f=e,l=!0,h="";return e||(l=!1,e="_@r"+(T+=1)),c=o(e),d=c[0],e=c[1],d&&(d=i(d,p,n),s=getOwn(j,d)),e&&(d?h=r?e:s&&s.normalize?s.normalize(e,function(e){return i(e,p,n)}):-1===e.indexOf("!")?i(e,p,n):e:(d=(c=o(h=i(e,p,n)))[0],h=c[1],r=!0,a=q.nameToUrl(h))),u=!d||s||r?"":"_unnormalized"+(A+=1),{prefix:d,name:h,parentMap:t,unnormalized:!!u,url:a,originalName:f,isDefine:l,id:(d?d+"!"+h:h)+u}}function s(e){var t=e.id,i=getOwn(S,t);return i||(i=S[t]=new q.Module(e)),i}function u(e,t,i){var r=e.id,n=getOwn(S,r);!hasProp(j,r)||n&&!n.defineEmitComplete?(n=s(e)).error&&"error"===t?i(n.error):n.on(t,i):"defined"===t&&i(j[r])}function c(e,t){var i=e.requireModules,r=!1;t?t(e):(each(i,function(t){var i=getOwn(S,t);i&&(i.error=e,i.events.error&&(r=!0,i.emit("error",e)))}),r||req.onError(e))}function d(){globalDefQueue.length&&(each(globalDefQueue,function(e){var t=e[0];"string"==typeof t&&(q.defQueueMap[t]=!0),O.push(e)}),globalDefQueue=[])}function p(e){delete S[e],delete k[e]}function f(e,t,i){var r=e.map.id;e.error?e.emit("error",e.error):(t[r]=!0,each(e.depMaps,function(r,n){var o=r.id,a=getOwn(S,o);!a||e.depMatched[n]||i[o]||(getOwn(t,o)?(e.defineDep(n,j[o]),e.check()):f(a,t,i))}),i[r]=!0)}function l(){var e,t,i=1e3*y.waitSeconds,o=i&&q.startTime+i<(new Date).getTime(),a=[],s=[],u=!1,d=!0;if(!x){if(x=!0,eachProp(k,function(e){var i=e.map,c=i.id;if(e.enabled&&(i.isDefine||s.push(e),!e.error))if(!e.inited&&o)n(c)?(t=!0,u=!0):(a.push(c),r(c));else if(!e.inited&&e.fetched&&i.isDefine&&(u=!0,!i.prefix))return d=!1}),o&&a.length)return e=makeError("timeout","Load timeout for modules: "+a,null,a),e.contextName=q.contextName,c(e);d&&each(s,function(e){f(e,{},{})}),o&&!t||!u||!isBrowser&&!isWebWorker||w||(w=setTimeout(function(){w=0,l()},50)),x=!1}}function h(e){hasProp(j,e[0])||s(a(e[0],null,!0)).init(e[1],e[2])}function m(e,t,i,r){e.detachEvent&&!isOpera?r&&e.detachEvent(r,t):e.removeEventListener(i,t,!1)}function g(e){var t=e.currentTarget||e.srcElement;return m(t,q.onScriptLoad,"load","onreadystatechange"),m(t,q.onScriptError,"error"),{node:t,id:t&&t.getAttribute("data-requiremodule")}}function v(){var e;for(d();O.length;){if(null===(e=O.shift())[0])return c(makeError("mismatch","Mismatched anonymous define() module: "+e[e.length-1]));h(e)}q.defQueueMap={}}var x,b,q,E,w,y={waitSeconds:7,baseUrl:"./",paths:{},bundles:{},pkgs:{},shim:{},config:{}},S={},k={},M={},O=[],j={},P={},R={},T=1,A=1;return E={require:function(e){return e.require?e.require:e.require=q.makeRequire(e.map)},exports:function(e){if(e.usingExports=!0,e.map.isDefine)return e.exports?j[e.map.id]=e.exports:e.exports=j[e.map.id]={}},module:function(e){return e.module?e.module:e.module={id:e.map.id,uri:e.map.url,config:function(){return getOwn(y.config,e.map.id)||{}},exports:e.exports||(e.exports={})}}},b=function(e){this.events=getOwn(M,e.id)||{},this.map=e,this.shim=getOwn(y.shim,e.id),this.depExports=[],this.depMaps=[],this.depMatched=[],this.pluginMaps={},this.depCount=0},b.prototype={init:function(e,t,i,r){r=r||{},this.inited||(this.factory=t,i?this.on("error",i):this.events.error&&(i=bind(this,function(e){this.emit("error",e)})),this.depMaps=e&&e.slice(0),this.errback=i,this.inited=!0,this.ignore=r.ignore,r.enabled||this.enabled?this.enable():this.check())},defineDep:function(e,t){this.depMatched[e]||(this.depMatched[e]=!0,this.depCount-=1,this.depExports[e]=t)},fetch:function(){if(!this.fetched){this.fetched=!0,q.startTime=(new Date).getTime();var e=this.map;if(!this.shim)return e.prefix?this.callPlugin():this.load();q.makeRequire(this.map,{enableBuildCallback:!0})(this.shim.deps||[],bind(this,function(){return e.prefix?this.callPlugin():this.load()}))}},load:function(){var e=this.map.url;P[e]||(P[e]=!0,q.load(this.map.id,e))},check:function(){if(this.enabled&&!this.enabling){var e,t,i=this.map.id,r=this.depExports,n=this.exports,o=this.factory;if(this.inited){if(this.error)this.emit("error",this.error);else if(!this.defining){if(this.defining=!0,this.depCount<1&&!this.defined){if(isFunction(o)){if(this.events.error&&this.map.isDefine||req.onError!==defaultOnError)try{n=q.execCb(i,o,r,n)}catch(t){e=t}else n=q.execCb(i,o,r,n);if(this.map.isDefine&&void 0===n&&((t=this.module)?n=t.exports:this.usingExports&&(n=this.exports)),e)return e.requireMap=this.map,e.requireModules=this.map.isDefine?[this.map.id]:null,e.requireType=this.map.isDefine?"define":"require",c(this.error=e)}else n=o;if(this.exports=n,this.map.isDefine&&!this.ignore&&(j[i]=n,req.onResourceLoad)){var a=[];each(this.depMaps,function(e){a.push(e.normalizedMap||e)}),req.onResourceLoad(q,this.map,a)}p(i),this.defined=!0}this.defining=!1,this.defined&&!this.defineEmitted&&(this.defineEmitted=!0,this.emit("defined",this.exports),this.defineEmitComplete=!0)}}else hasProp(q.defQueueMap,i)||this.fetch()}},callPlugin:function(){var e=this.map,t=e.id,r=a(e.prefix);this.depMaps.push(r),u(r,"defined",bind(this,function(r){var n,o,d,f=getOwn(R,this.map.id),l=this.map.name,h=this.map.parentMap?this.map.parentMap.name:null,m=q.makeRequire(e.parentMap,{enableBuildCallback:!0});return this.map.unnormalized?(r.normalize&&(l=r.normalize(l,function(e){return i(e,h,!0)})||""),o=a(e.prefix+"!"+l,this.map.parentMap,!0),u(o,"defined",bind(this,function(e){this.map.normalizedMap=o,this.init([],function(){return e},null,{enabled:!0,ignore:!0})})),void((d=getOwn(S,o.id))&&(this.depMaps.push(o),this.events.error&&d.on("error",bind(this,function(e){this.emit("error",e)})),d.enable()))):f?(this.map.url=q.nameToUrl(f),void this.load()):((n=bind(this,function(e){this.init([],function(){return e},null,{enabled:!0})})).error=bind(this,function(e){this.inited=!0,this.error=e,e.requireModules=[t],eachProp(S,function(e){0===e.map.id.indexOf(t+"_unnormalized")&&p(e.map.id)}),c(e)}),n.fromText=bind(this,function(i,r){var o=e.name,u=a(o),d=useInteractive;r&&(i=r),d&&(useInteractive=!1),s(u),hasProp(y.config,t)&&(y.config[o]=y.config[t]);try{req.exec(i)}catch(e){return c(makeError("fromtexteval","fromText eval for "+t+" failed: "+e,e,[t]))}d&&(useInteractive=!0),this.depMaps.push(u),q.completeLoad(o),m([o],n)}),void r.load(e.name,m,n,y))})),q.enable(r,this),this.pluginMaps[r.id]=r},enable:function(){k[this.map.id]=this,this.enabled=!0,this.enabling=!0,each(this.depMaps,bind(this,function(e,t){var i,r,n;if("string"==typeof e){if(e=a(e,this.map.isDefine?this.map:this.map.parentMap,!1,!this.skipMap),this.depMaps[t]=e,n=getOwn(E,e.id))return void(this.depExports[t]=n(this));this.depCount+=1,u(e,"defined",bind(this,function(e){this.undefed||(this.defineDep(t,e),this.check())})),this.errback?u(e,"error",bind(this,this.errback)):this.events.error&&u(e,"error",bind(this,function(e){this.emit("error",e)}))}i=e.id,r=S[i],hasProp(E,i)||!r||r.enabled||q.enable(e,this)})),eachProp(this.pluginMaps,bind(this,function(e){var t=getOwn(S,e.id);t&&!t.enabled&&q.enable(e,this)})),this.enabling=!1,this.check()},on:function(e,t){var i=this.events[e];i||(i=this.events[e]=[]),i.push(t)},emit:function(e,t){each(this.events[e],function(e){e(t)}),"error"===e&&delete this.events[e]}},q={config:y,contextName:e,registry:S,defined:j,urlFetched:P,defQueue:O,defQueueMap:{},Module:b,makeModuleMap:a,nextTick:req.nextTick,onError:c,configure:function(e){if(e.baseUrl&&"/"!==e.baseUrl.charAt(e.baseUrl.length-1)&&(e.baseUrl+="/"),"string"==typeof e.urlArgs){var t=e.urlArgs;e.urlArgs=function(e,i){return(-1===i.indexOf("?")?"?":"&")+t}}var i=y.shim,r={paths:!0,bundles:!0,config:!0,map:!0};eachProp(e,function(e,t){r[t]?(y[t]||(y[t]={}),mixin(y[t],e,!0,!0)):y[t]=e}),e.bundles&&eachProp(e.bundles,function(e,t){each(e,function(e){e!==t&&(R[e]=t)})}),e.shim&&(eachProp(e.shim,function(e,t){isArray(e)&&(e={deps:e}),!e.exports&&!e.init||e.exportsFn||(e.exportsFn=q.makeShimExports(e)),i[t]=e}),y.shim=i),e.packages&&each(e.packages,function(e){var t;t=(e="string"==typeof e?{name:e}:e).name,e.location&&(y.paths[t]=e.location),y.pkgs[t]=e.name+"/"+(e.main||"main").replace(currDirRegExp,"").replace(jsSuffixRegExp,"")}),eachProp(S,function(e,t){e.inited||e.map.unnormalized||(e.map=a(t,null,!0))}),(e.deps||e.callback)&&q.require(e.deps||[],e.callback)},makeShimExports:function(e){return function(){var t;return e.init&&(t=e.init.apply(global,arguments)),t||e.exports&&getGlobal(e.exports)}},makeRequire:function(t,n){function o(i,r,u){var d,p,f;return n.enableBuildCallback&&r&&isFunction(r)&&(r.__requireJsBuild=!0),"string"==typeof i?isFunction(r)?c(makeError("requireargs","Invalid require call"),u):t&&hasProp(E,i)?E[i](S[t.id]):req.get?req.get(q,i,t,o):(p=a(i,t,!1,!0),d=p.id,hasProp(j,d)?j[d]:c(makeError("notloaded",'Module name "'+d+'" has not been loaded yet for context: '+e+(t?"":". Use require([])")))):(v(),q.nextTick(function(){v(),(f=s(a(null,t))).skipMap=n.skipMap,f.init(i,r,u,{enabled:!0}),l()}),o)}return n=n||{},mixin(o,{isBrowser:isBrowser,toUrl:function(e){var r,n=e.lastIndexOf("."),o=e.split("/")[0],a="."===o||".."===o;return-1!==n&&(!a||n>1)&&(r=e.substring(n,e.length),e=e.substring(0,n)),q.nameToUrl(i(e,t&&t.id,!0),r,!0)},defined:function(e){return hasProp(j,a(e,t,!1,!0).id)},specified:function(e){return e=a(e,t,!1,!0).id,hasProp(j,e)||hasProp(S,e)}}),t||(o.undef=function(e){d();var i=a(e,t,!0),n=getOwn(S,e);n.undefed=!0,r(e),delete j[e],delete P[i.url],delete M[e],eachReverse(O,function(t,i){t[0]===e&&O.splice(i,1)}),delete q.defQueueMap[e],n&&(n.events.defined&&(M[e]=n.events),p(e))}),o},enable:function(e){getOwn(S,e.id)&&s(e).enable()},completeLoad:function(e){var t,i,r,o=getOwn(y.shim,e)||{},a=o.exports;for(d();O.length;){if(null===(i=O.shift())[0]){if(i[0]=e,t)break;t=!0}else i[0]===e&&(t=!0);h(i)}if(q.defQueueMap={},r=getOwn(S,e),!t&&!hasProp(j,e)&&r&&!r.inited){if(!(!y.enforceDefine||a&&getGlobal(a)))return n(e)?void 0:c(makeError("nodefine","No define call for "+e,null,[e]));h([e,o.deps||[],o.exportsFn])}l()},nameToUrl:function(e,t,i){var r,n,o,a,s,u,c,d=getOwn(y.pkgs,e);if(d&&(e=d),c=getOwn(R,e))return q.nameToUrl(c,t,i);if(req.jsExtRegExp.test(e))s=e+(t||"");else{for(r=y.paths,o=(n=e.split("/")).length;o>0;o-=1)if(a=n.slice(0,o).join("/"),u=getOwn(r,a)){isArray(u)&&(u=u[0]),n.splice(0,o,u);break}s=n.join("/"),s=("/"===(s+=t||(/^data\:|^blob\:|\?/.test(s)||i?"":".js")).charAt(0)||s.match(/^[\w\+\.\-]+:/)?"":y.baseUrl)+s}return y.urlArgs&&!/^blob\:/.test(s)?s+y.urlArgs(e,s):s},load:function(e,t){req.load(q,e,t)},execCb:function(e,t,i,r){return t.apply(r,i)},onScriptLoad:function(e){if("load"===e.type||readyRegExp.test((e.currentTarget||e.srcElement).readyState)){interactiveScript=null;var t=g(e);q.completeLoad(t.id)}},onScriptError:function(e){var t=g(e);if(!n(t.id)){var i=[];return eachProp(S,function(e,r){0!==r.indexOf("_@r")&&each(e.depMaps,function(e){if(e.id===t.id)return i.push(r),!0})}),c(makeError("scripterror",'Script error for "'+t.id+(i.length?'", needed by: '+i.join(", "):'"'),e,[t.id]))}}},q.require=q.makeRequire(),q}function getInteractiveScript(){return interactiveScript&&"interactive"===interactiveScript.readyState?interactiveScript:(eachReverse(scripts(),function(e){if("interactive"===e.readyState)return interactiveScript=e}),interactiveScript)}var req,s,head,baseElement,dataMain,src,interactiveScript,currentlyAddingScript,mainScript,subPath,version="2.3.4",commentRegExp=/\/\*[\s\S]*?\*\/|([^:"'=]|^)\/\/.*$/gm,cjsRequireRegExp=/[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g,jsSuffixRegExp=/\.js$/,currDirRegExp=/^\.\//,op=Object.prototype,ostring=op.toString,hasOwn=op.hasOwnProperty,isBrowser=!("undefined"==typeof window||"undefined"==typeof navigator||!window.document),isWebWorker=!isBrowser&&"undefined"!=typeof importScripts,readyRegExp=isBrowser&&"PLAYSTATION 3"===navigator.platform?/^complete$/:/^(complete|loaded)$/,defContextName="_",isOpera="undefined"!=typeof opera&&"[object Opera]"===opera.toString(),contexts={},cfg={},globalDefQueue=[],useInteractive=!1;if(void 0===define){if(void 0!==requirejs){if(isFunction(requirejs))return;cfg=requirejs,requirejs=void 0}void 0===require||isFunction(require)||(cfg=require,require=void 0),req=requirejs=function(e,t,i,r){var n,o,a=defContextName;return isArray(e)||"string"==typeof e||(o=e,isArray(t)?(e=t,t=i,i=r):e=[]),o&&o.context&&(a=o.context),(n=getOwn(contexts,a))||(n=contexts[a]=req.s.newContext(a)),o&&n.configure(o),n.require(e,t,i)},req.config=function(e){return req(e)},req.nextTick=void 0!==setTimeout?function(e){setTimeout(e,4)}:function(e){e()},require||(require=req),req.version=version,req.jsExtRegExp=/^\/|:|\?|\.js$/,req.isBrowser=isBrowser,s=req.s={contexts:contexts,newContext:newContext},req({}),each(["toUrl","undef","defined","specified"],function(e){req[e]=function(){var t=contexts[defContextName];return t.require[e].apply(t,arguments)}}),isBrowser&&(head=s.head=document.getElementsByTagName("head")[0],(baseElement=document.getElementsByTagName("base")[0])&&(head=s.head=baseElement.parentNode)),req.onError=defaultOnError,req.createNode=function(e,t,i){var r=e.xhtml?document.createElementNS("http://www.w3.org/1999/xhtml","html:script"):document.createElement("script");return r.type=e.scriptType||"text/javascript",r.charset="utf-8",r.async=!0,r},req.load=function(e,t,i){var r,n=e&&e.config||{};if(isBrowser)return(r=req.createNode(n,t,i)).setAttribute("data-requirecontext",e.contextName),r.setAttribute("data-requiremodule",t),!r.attachEvent||r.attachEvent.toString&&r.attachEvent.toString().indexOf("[native code")<0||isOpera?(r.addEventListener("load",e.onScriptLoad,!1),r.addEventListener("error",e.onScriptError,!1)):(useInteractive=!0,r.attachEvent("onreadystatechange",e.onScriptLoad)),r.src=i,n.onNodeCreated&&n.onNodeCreated(r,n,t,i),currentlyAddingScript=r,baseElement?head.insertBefore(r,baseElement):head.appendChild(r),currentlyAddingScript=null,r;if(isWebWorker)try{setTimeout(function(){},0),importScripts(i),e.completeLoad(t)}catch(r){e.onError(makeError("importscripts","importScripts failed for "+t+" at "+i,r,[t]))}},isBrowser&&!cfg.skipDataMain&&eachReverse(scripts(),function(e){if(head||(head=e.parentNode),dataMain=e.getAttribute("data-main"))return mainScript=dataMain,cfg.baseUrl||-1!==mainScript.indexOf("!")||(src=mainScript.split("/"),mainScript=src.pop(),subPath=src.length?src.join("/")+"/":"./",cfg.baseUrl=subPath),mainScript=mainScript.replace(jsSuffixRegExp,""),req.jsExtRegExp.test(mainScript)&&(mainScript=dataMain),cfg.deps=cfg.deps?cfg.deps.concat(mainScript):[mainScript],!0}),define=function(e,t,i){var r,n;"string"!=typeof e&&(i=t,t=e,e=null),isArray(t)||(i=t,t=null),!t&&isFunction(i)&&(t=[],i.length&&(i.toString().replace(commentRegExp,commentReplace).replace(cjsRequireRegExp,function(e,i){t.push(i)}),t=(1===i.length?["require"]:["require","exports","module"]).concat(t))),useInteractive&&(r=currentlyAddingScript||getInteractiveScript())&&(e||(e=r.getAttribute("data-requiremodule")),n=contexts[r.getAttribute("data-requirecontext")]),n?(n.defQueue.push([e,t,i]),n.defQueueMap[e]=!0):globalDefQueue.push([e,t,i])},define.amd={jQuery:!0},req.exec=function(text){return eval(text)},req(cfg)}}(this,"undefined"==typeof setTimeout?void 0:setTimeout); diff --git a/src/jsMain/resources/style/style.scss b/src/jsMain/resources/style/style.scss new file mode 100644 index 0000000..1483aa4 --- /dev/null +++ b/src/jsMain/resources/style/style.scss @@ -0,0 +1,427 @@ +@mixin no-select() { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +$background-primary-color: #fff; +$background-secondary-color: #fcfcfc; + +$text-primary-color: #333; + +$primary-color: #B11D33; +$primary-text-color: #fff; + +$error-color: #D55225; +$error-text-color: #fff; + +$border-color: #888; + +$transitionTime: 150ms; + +$bg-disabled-color: rgba($text-primary-color, .26); +$bg-enabled-color: rgba($primary-color, .5); +$lever-disabled-color: $background-primary-color; +$lever-enabled-color: $primary-color; + +body, html { + color: $text-primary-color; + background: $background-secondary-color; + + font-family: 'Montserrat', Roboto, Arial, sans-serif; + font-weight: 600; + + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} + +a { + text-decoration: none; + outline: none; + color: $primary-color; + + &:hover { + text-decoration: none; + } + + &:active { + color: $primary-color; + } +} + +.container { + width: 100%; + margin: 0 auto; +} + +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + box-sizing: border-box; +} + +@media (min-width: 768px) { + .container { + width: 720px; + } +} + +@media (min-width: 992px) { + .container { + width: 960px; + } +} + +@media (min-width: 1200px) { + .container { + width: 1140px; + } +} + +.menu { + background-color: $background-secondary-color; + color: $text-primary-color; + width: 100%; + clear: both; + height: 6rem; + line-height: 6rem; + + a { + padding: 0 1rem; + color: $text-primary-color; + height: 100%; + display: inline-block; + font-family: "Bungee", sans-serif; + font-weight: normal; + font-size: 1.1rem; + position: relative; + text-transform: uppercase; + white-space: nowrap; + + &::after { + content: ''; + display: block; + position: absolute; + left: 1rem; + right: 1rem; + bottom: 1.8em; + height: 0.25rem; + + background: transparent; + transition: background-color $transitionTime; + } + + &.active::after { + background: $text-primary-color; + } + + &:hover { + background-color: rgba($primary-text-color, 0.1); + + &::after { + background: $primary-color; + } + } + } + + & ~ * { + content: ''; + clear: both; + } +} + +.menu-left { + float: left; +} + +.menu-right { + float: right; + + .menu-icon { + display: block; + cursor: default; + + padding: 0 1rem; + color: $text-primary-color; + height: 100%; + font-size: 2rem; + position: relative; + } + + .menu-content { + display: none; + position: absolute; + background-color: $background-secondary-color; + z-index: 1; + left: 1rem; + right: 1rem; + + a { + display: block; + line-height: 3rem; + &:after { + bottom: 0.2rem; + } + } + } + + &:hover { + .menu-content { + display: block; + } + } +} + +@media (min-width: 992px) { + .menu-right { + .menu-icon { + display: none; + } + + .menu-content { + display: block; + position: static; + background-color: transparent; + z-index: unset; + + a { + display: inline-block; + line-height: unset; + &:after { + bottom: 1.8em + } + } + } + } +} + +.main { + padding: 0 1rem; +} + +.table-layout-search { + float: left; + padding-bottom: 0.5rem !important; + + input { + padding-right: 4rem; + } + + .btn-search { + position: absolute; + top: 0; + height: calc(2.5rem + 2px); + line-height: 2.5rem; + right: -3px; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + + margin-top: 1px; + margin-right: 2px; + } + + input:focus ~ .btn-search { + border-color: $primary-color; + border-width: 2px; + margin-top: 0; + height: calc(2.5rem + 4px); + margin-right: 1px; + } +} + +.table-layout-action { + float: right; + line-height: 2.5rem; +} + +.table-layout-table { + width: 100%; + border-spacing: 0; + border-collapse: collapse; + clear: both; + + th { + text-align: start; + } + + th, td { + padding: 0 0.6rem; + } + + .action { + text-align: center; + } + + tr { + border-top: solid 1px rgba($text-primary-color, 0.1); + + &:first-child { + background-color: rgba($text-primary-color, 0.06); + height: 2.5rem; + line-height: 2.5rem; + } + + &:not(:first-child) { + height: 2rem; + line-height: 2rem; + + &:hover { + background-color: rgba($text-primary-color, 0.06); + } + } + } +} + +.form-control { + border: solid 1px $border-color; + outline: none; + padding: 0 1rem; + line-height: 2.5rem; + width: 100%; + background-color: $background-primary-color; + border-radius: 0.2rem; + margin: 1px; + transition: border-color $transitionTime; + + &:focus { + border-color: $primary-color; + border-width: 2px; + margin: 0; + } +} + +.form-group { + padding-bottom: 1rem; + display: block; + position: relative; + clear: both; + + label { + display: block; + padding-bottom: 0.3rem; + padding-left: 0.2rem; + } +} + +.form-switch { + line-height: 24px; + + input { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + opacity: 0; + z-index: 0; + } + + label { + display: block; + padding: 0 0 0 44px; + cursor: pointer; + + &:before { + content: ''; + position: absolute; + top: 5px; + left: 0; + width: 36px; + height: 14px; + background-color: $bg-disabled-color; + border-radius: 14px; + z-index: 1; + transition: background-color 0.28s cubic-bezier(.4, 0, .2, 1); + } + + &:after { + content: ''; + position: absolute; + top: 2px; + left: 0; + width: 20px; + height: 20px; + background-color: $lever-disabled-color; + border-radius: 14px; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, .14), 0 3px 1px -2px rgba(0, 0, 0, .2), 0 1px 5px 0 rgba(0, 0, 0, .12); + z-index: 2; + transition: all 0.28s cubic-bezier(.4, 0, .2, 1); + transition-property: left, background-color; + } + + @include no-select + } + + input:checked + label { + &:before { + background-color: $bg-enabled-color; + } + + &:after { + left: 16px; + background-color: $lever-enabled-color; + } + } +} + +.form-switch-group { + padding: 0.5rem 0; +} + +.form-btn { + border: solid 1px $border-color; + outline: none; + padding: 0 1rem; + line-height: 2rem; + background-color: $background-primary-color; + color: $primary-color; + + display: inline-block; + margin-right: 0.6rem; + + border-radius: 0.2rem; + font-weight: 600; + text-transform: uppercase; + font-size: 0.9rem; + + transition: border-color $transitionTime, outline-color $transitionTime; + + cursor: pointer; + + &:focus, &:hover { + border-color: $primary-color; + outline-color: $primary-color; + } +} + +.btn-primary { + background-color: $primary-color; + color: $primary-text-color; + border-color: $primary-color; +} + +.btn-danger { + background-color: $error-color; + color: $error-text-color; + border-color: $error-color; +} + +button::-moz-focus-inner, input::-moz-focus-inner { + border: 0; +} + +form { + margin-bottom: 2rem; + width: 100%; +} + +@media (min-width: 768px) { + form { + width: 24rem; + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/Application.kt b/src/jvmMain/kotlin/de/kif/backend/Application.kt new file mode 100644 index 0000000..75caf31 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/Application.kt @@ -0,0 +1,154 @@ +package de.kif.backend + +import de.kif.backend.database.Connection +import de.kif.backend.model.User +import de.kif.backend.route.* +import io.ktor.application.Application +import io.ktor.application.ApplicationCall +import io.ktor.application.install +import io.ktor.application.log +import io.ktor.auth.Authentication +import io.ktor.auth.FormAuthChallenge +import io.ktor.auth.UserPasswordCredential +import io.ktor.auth.form +import io.ktor.features.* +import io.ktor.http.content.files +import io.ktor.http.content.static +import io.ktor.locations.Location +import io.ktor.locations.Locations +import io.ktor.response.respondRedirect +import io.ktor.routing.routing +import io.ktor.sessions.* +import io.ktor.util.hex +import kotlinx.coroutines.launch + +data class PortalSession(val id: Int, val name: String) { + suspend fun getUser(call: ApplicationCall): User { + val user = User.find(name) + if (user == null || user.id != id) { + call.sessions.clear() + call.respondRedirect("/login?error") + throw IllegalAccessException() + } + return user + } +} + +@Location("/") +class LocationDashboard() + +@Location("/calendar") +class LocationCalendar() + +@Location("/login") +data class LocationLogin(val username: String = "", val password: String = "", val next: String = "/") + +@Location("/logout") +class LocationLogout() + +@Location("/account") +class LocationAccount() + +@Location("/user") +data class LocationUser(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("/workgroup") +data class LocationWorkGroup(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}") + data class Edit(val id: Int) + + @Location("/new") + class New() + + @Location("/{id}/delete") + data class Delete(val id: Int) +} + +@Location("/person") +data class LocationPerson(val search: String = "") { + @Location("/{id}") + data class Edit(val id: Int) + + @Location("/new") + class New() + + @Location("/{id}/delete") + data class Delete(val id: Int) +} + +fun Application.main() { + Connection.init() + + install(DefaultHeaders) + install(CallLogging) + install(ConditionalHeaders) + install(Compression) + install(DataConversion) + install(Locations) + + install(Authentication) { + form { + userParamName = LocationLogin::username.name + passwordParamName = LocationLogin::password.name + challenge = FormAuthChallenge.Redirect { _ -> + "/login?error" + } + validate { credential: UserPasswordCredential -> + val user = User.find(credential.name) ?: return@validate null + if (user.checkPassword(credential.password)) user else null + } + } + } + + val sessionKey = hex("1234567890abcdef") //TODO + install(Sessions) { + cookie("SESSION") { + transform(SessionTransportTransformerMessageAuthentication(sessionKey)) + } + } + + routing { + static("/static") { + files(Resources.directory) + } + + launch { + val firstStart = User.exists() + if (firstStart) { + log.info("Please create the first user and restart the server!") + setup() + } else { + dashboard() + calendar() + login() + account() + + workGroup() + room() + person() + user() + } + } + } +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/de/kif/backend/Main.kt b/src/jvmMain/kotlin/de/kif/backend/Main.kt new file mode 100644 index 0000000..1d5a852 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/Main.kt @@ -0,0 +1,18 @@ +package de.kif.backend + +import io.ktor.application.Application +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty + +object Main { + @Suppress("UnusedMainParameter") + @JvmStatic + fun main(args: Array) { + embeddedServer( + factory = Netty, + port = 8080, + host = "0.0.0.0", + module = Application::main + ).start(wait = true) + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/Resources.kt b/src/jvmMain/kotlin/de/kif/backend/Resources.kt new file mode 100644 index 0000000..ca720f8 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/Resources.kt @@ -0,0 +1,73 @@ +package de.kif.backend + +import mu.KotlinLogging +import java.io.File +import java.net.URI +import java.nio.file.* + +/** + * Manage static resource files of the ui + */ +object Resources { + + private val logger = KotlinLogging.logger {} + + /** + * Extract web directory from jar file. + * + * @return File that points the extracted web directory. + */ + private fun extractWebFolder(): File { + val destination: Path = Files.createTempDirectory("web") + + val classPath = "web" + val uri: URI = this::class.java.classLoader.getResource(classPath).toURI() + val fileSystem: FileSystem? + val src: Path = if (uri.scheme == "jar") { + fileSystem = FileSystems.newFileSystem(uri, mutableMapOf()) + fileSystem.getPath(classPath) + } else { + Paths.get(uri) + } + + Files.walk(src).forEach { path: Path -> + val name = path.toAbsolutePath().toString().let { name -> + name.drop(classPath.length + name.indexOf(classPath, ignoreCase = true)).dropWhile { + it == '/' || it == '\\' + } + } + if (Files.isDirectory(path)) { + Files.createDirectories(destination.resolve(name)) + } else { + Files.copy(path, destination.resolve(name)) + } + } + + return destination.toFile() + } + + /** + * File that points the web directory. + */ + val directory = File(".").absoluteFile.canonicalFile.let { currentDir -> + listOf( + "web", + "../web" + ).map { + File(currentDir, it) + }.firstOrNull { it.isDirectory }?.absoluteFile?.canonicalFile ?: extractWebFolder() + }.also { + logger.info { "Web directory: $it" } + } + + /** + * List of js modules to be included. + */ + val jsModules = directory.list().toList().filter { + it.endsWith(".js") && + !it.endsWith(".meta.js") && + !it.endsWith("-test.js") && + it != "require.min.js" + }.joinToString(", ") { "'${it.take(it.length - 3)}'" } + +} diff --git a/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt b/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt new file mode 100644 index 0000000..c4ddb0c --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/database/Connection.kt @@ -0,0 +1,31 @@ +package de.kif.backend.database + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.transactions.TransactionManager +import org.jetbrains.exposed.sql.transactions.transaction +import java.sql.Connection.TRANSACTION_SERIALIZABLE + + +object Connection { + fun init() { + Database.connect("jdbc:sqlite:portal.db", "org.sqlite.JDBC") + TransactionManager.manager.defaultIsolationLevel = TRANSACTION_SERIALIZABLE + + transaction { + SchemaUtils.create( + DbPerson, DbPersonConstraint, + DbTrack, DbWorkGroup, DbWorkGroupConstraint, + DbLeader, DbWorkGroupOrder, + DbRoom, DbTimeSlot, DbSchedule, + DbUser, DbUserPermission + ) + } + } +} + +suspend fun dbQuery(block: () -> T): T = withContext(Dispatchers.IO) { + transaction { block() } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt b/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt new file mode 100644 index 0000000..9c07e59 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/database/Schema.kt @@ -0,0 +1,100 @@ +package de.kif.backend.database + +import de.kif.backend.model.Permission +import org.jetbrains.exposed.sql.Table + +object DbPerson : Table() { + val id = integer("id").autoIncrement().primaryKey() + val firstName = varchar("first_name", 64) + val lastName = varchar("last_name", 64) + + val arrival = long("arrival").nullable() + val departure = long("departure").nullable() +} + +object DbPersonConstraint : Table() { + val id = integer("id").autoIncrement().primaryKey() + val personId = integer("person_id") + + val type = enumeration("type", DbConstraintType::class) + val time = long("time") + val duration = integer("duration").default(0) + val day = integer("day") +} + +object DbTrack : Table() { + val id = integer("id").autoIncrement().primaryKey() + val name = varchar("name", 64) +} + +object DbWorkGroup : Table() { + val id = integer("id").autoIncrement().primaryKey() + val name = varchar("first_name", 64) + + val interested = integer("interested") + val trackId = integer("track_id").nullable() + val projector = bool("projector") + val resolution = bool("resolution") + + val length = integer("length") + + val start = long("start").nullable() + val end = long("end").nullable() +} + +object DbWorkGroupConstraint : Table() { + val id = integer("id").autoIncrement().primaryKey() + val workGroupId = integer("work_group_id") + + val type = enumeration("type", DbConstraintType::class) + val time = long("time") + val duration = integer("duration").default(0) + val day = integer("day") +} + +object DbLeader : Table() { + val workGroupId = integer("work_group_id").primaryKey(0) + val personId = integer("person_id").primaryKey(1) +} + +object DbWorkGroupOrder : Table() { + val beforeWorkGroupId = integer("before_work_group_id").primaryKey(0) + val afterWorkGroupId = integer("after_work_group_id").primaryKey(1) +} + +object DbRoom : Table() { + val id = integer("id").autoIncrement().primaryKey() + val name = varchar("name", 64) + + val places = integer("places") + 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) +} + +enum class DbConstraintType { + BEGIN, END, BLOCKED +} + +object DbUser : Table() { + val userId = integer("id").autoIncrement().primaryKey() + val username = varchar("username", 64).uniqueIndex() + val password = varchar("password", 64) +} + +object DbUserPermission : Table() { + val userId = integer("id").primaryKey(0) + val permission = enumeration("permission", Permission::class).primaryKey(1) +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/Day.kt b/src/jvmMain/kotlin/de/kif/backend/model/Day.kt new file mode 100644 index 0000000..9461387 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/Day.kt @@ -0,0 +1,4 @@ +package de.kif.backend.model + +class Day { +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/de/kif/backend/model/Permission.kt b/src/jvmMain/kotlin/de/kif/backend/model/Permission.kt new file mode 100644 index 0000000..67b0ca6 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/Permission.kt @@ -0,0 +1,5 @@ +package de.kif.backend.model + +enum class Permission { + USER, SCHEDULE, WORK_GROUP, ROOM, PERSON, ADMIN +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/Person.kt b/src/jvmMain/kotlin/de/kif/backend/model/Person.kt new file mode 100644 index 0000000..efe6fd7 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/Person.kt @@ -0,0 +1,89 @@ +package de.kif.backend.model + +import de.kif.backend.database.DbPerson +import de.kif.backend.database.DbPersonConstraint +import de.kif.backend.database.dbQuery +import org.jetbrains.exposed.sql.* + +class Person( + var id: Int = -1, + var firstName: String = "", + var lastName: String = "", + var arrival: Long? = null, + var departure: Long? = null +) { + var constraints: Set = emptySet() + + suspend fun save() { + if (id < 0) { + dbQuery { + val newId = DbPerson.insert { + it[firstName] = this@Person.firstName + it[lastName] = this@Person.lastName + it[arrival] = this@Person.arrival + it[departure] = this@Person.departure + }[DbPerson.id]!! + this@Person.id = newId + } + for (constraint in constraints) { + constraint.save(this@Person.id) + } + } else { + dbQuery { + DbPerson.update({ DbPerson.id eq id }) { + it[firstName] = this@Person.firstName + it[lastName] = this@Person.lastName + it[arrival] = this@Person.arrival + it[departure] = this@Person.departure + } + + DbPersonConstraint.deleteWhere { DbPersonConstraint.personId eq id } + } + for (constraint in constraints) { + constraint.save(this@Person.id) + } + } + } + + suspend fun delete() { + val id = id + if (id >= 0) { + dbQuery { + DbPersonConstraint.deleteWhere { DbPersonConstraint.personId eq id } + DbPerson.deleteWhere { DbPerson.id eq id } + } + } + } + + suspend fun loadConstraints() { + if (id >= 0) { + constraints = PersonConstraint.get(id) + } + } + + companion object { + suspend fun get(personId: Int): Person? = dbQuery { + val result = DbPerson.select { DbPerson.id eq personId }.firstOrNull() ?: return@dbQuery null + Person( + result[DbPerson.id], + result[DbPerson.firstName], + result[DbPerson.lastName], + result[DbPerson.arrival], + result[DbPerson.departure] + ) + }?.apply { loadConstraints() } + + suspend fun list(): List = dbQuery { + val query = DbPerson.selectAll() + query.map { result -> + Person( + result[DbPerson.id], + result[DbPerson.firstName], + result[DbPerson.lastName], + result[DbPerson.arrival], + result[DbPerson.departure] + ) + } + }.onEach { it.loadConstraints() } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/PersonConstraint.kt b/src/jvmMain/kotlin/de/kif/backend/model/PersonConstraint.kt new file mode 100644 index 0000000..6754e89 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/PersonConstraint.kt @@ -0,0 +1,132 @@ +package de.kif.backend.model + +import de.kif.backend.database.DbConstraintType +import de.kif.backend.database.DbPersonConstraint +import de.kif.backend.database.dbQuery +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.update + +sealed class PersonConstraint( + var id: Int = -1 +) { + + abstract suspend fun save(personId: Int) + + suspend fun delete() { + val id = id + if (id >= 0) { + dbQuery { + DbPersonConstraint.deleteWhere { DbPersonConstraint.id eq id } + } + } + } + + class BeginOnDay( + var time: Long = 0, + var day: Int = 0 + ) : PersonConstraint() { + override suspend fun save(personId: Int) { + if (id < 0) { + dbQuery { + val newId = DbPersonConstraint.insert { + it[this@insert.personId] = personId + it[type] = DbConstraintType.BEGIN + it[time] = this@BeginOnDay.time + it[day] = this@BeginOnDay.day + }[DbPersonConstraint.id]!! + this@BeginOnDay.id = newId + } + } else { + dbQuery { + DbPersonConstraint.update({ DbPersonConstraint.id eq id }) { + it[this@update.personId] = personId + it[type] = DbConstraintType.BEGIN + it[time] = this@BeginOnDay.time + it[day] = this@BeginOnDay.day + } + } + } + } + } + + class EndOnDay( + var time: Long = 0, + var day: Int = 0 + ) : PersonConstraint() { + override suspend fun save(personId: Int) { + if (id < 0) { + dbQuery { + val newId = DbPersonConstraint.insert { + it[this@insert.personId] = personId + it[type] = DbConstraintType.END + it[time] = this@EndOnDay.time + it[day] = this@EndOnDay.day + }[DbPersonConstraint.id]!! + this@EndOnDay.id = newId + } + } else { + dbQuery { + DbPersonConstraint.update({ DbPersonConstraint.id eq id }) { + it[this@update.personId] = personId + it[type] = DbConstraintType.END + it[time] = this@EndOnDay.time + it[day] = this@EndOnDay.day + } + } + } + } + } + + class BlockedOnDay( + var time: Long = 0, + var duration: Int = 0, + var day: Int = 0 + ) : PersonConstraint() { + + override suspend fun save(personId: Int) { + if (id < 0) { + dbQuery { + val newId = DbPersonConstraint.insert { + it[this@insert.personId] = personId + it[type] = DbConstraintType.BLOCKED + it[time] = this@BlockedOnDay.time + it[duration] = this@BlockedOnDay.duration + it[day] = this@BlockedOnDay.day + }[DbPersonConstraint.id]!! + this@BlockedOnDay.id = newId + } + } else { + dbQuery { + DbPersonConstraint.update({ DbPersonConstraint.id eq id }) { + it[this@update.personId] = personId + it[type] = DbConstraintType.BLOCKED + it[time] = this@BlockedOnDay.time + it[duration] = this@BlockedOnDay.duration + it[day] = this@BlockedOnDay.day + } + } + } + } + } + + companion object { + suspend fun get(personId: Int): Set = dbQuery { + val result = DbPersonConstraint.select { DbPersonConstraint.personId eq personId } + result.map { + val id = it[DbPersonConstraint.id] + val type = it[DbPersonConstraint.type] + val time = it[DbPersonConstraint.time] + val duration = it[DbPersonConstraint.duration] + val day = it[DbPersonConstraint.day] + + when (type) { + DbConstraintType.BEGIN -> PersonConstraint.BeginOnDay(time, day) + DbConstraintType.END -> PersonConstraint.EndOnDay(time, day) + DbConstraintType.BLOCKED -> PersonConstraint.BlockedOnDay(time, duration, day) + }.also { it.id = id } + }.toSet() + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/Room.kt b/src/jvmMain/kotlin/de/kif/backend/model/Room.kt new file mode 100644 index 0000000..3d9ced9 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/Room.kt @@ -0,0 +1,66 @@ +package de.kif.backend.model + +import de.kif.backend.database.DbRoom +import de.kif.backend.database.dbQuery +import org.jetbrains.exposed.sql.* + +class Room( + var id: Int = -1, + var name: String = "", + var places: Int = 0, + var projector: Boolean = false +) { + suspend fun save() { + if (id < 0) { + dbQuery { + val newId = DbRoom.insert { + it[name] = this@Room.name + it[places] = this@Room.places + it[projector] = this@Room.projector + }[DbRoom.id]!! + this@Room.id = newId + } + } else { + dbQuery { + DbRoom.update({ DbRoom.id eq id }) { + it[name] = this@Room.name + it[places] = this@Room.places + it[projector] = this@Room.projector + } + } + } + } + + suspend fun delete() { + val id = id + if (id >= 0) { + dbQuery { + DbRoom.deleteWhere { DbRoom.id eq id } + } + } + } + + companion object { + suspend fun get(roomId: Int): Room? = dbQuery { + val result = DbRoom.select { DbRoom.id eq roomId }.firstOrNull() ?: return@dbQuery null + Room( + result[DbRoom.id], + result[DbRoom.name], + result[DbRoom.places], + result[DbRoom.projector] + ) + } + + suspend fun list(): List = dbQuery { + val query = DbRoom.selectAll() + query.map { result -> + Room( + result[DbRoom.id], + result[DbRoom.name], + result[DbRoom.places], + result[DbRoom.projector] + ) + } + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/TimeSlot.kt b/src/jvmMain/kotlin/de/kif/backend/model/TimeSlot.kt new file mode 100644 index 0000000..37122f2 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/TimeSlot.kt @@ -0,0 +1,4 @@ +package de.kif.backend.model + +class TimeSlot { +} \ No newline at end of file diff --git a/src/jvmMain/kotlin/de/kif/backend/model/User.kt b/src/jvmMain/kotlin/de/kif/backend/model/User.kt new file mode 100644 index 0000000..85aee20 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/User.kt @@ -0,0 +1,110 @@ +package de.kif.backend.model + +import de.kif.backend.database.DbUser +import de.kif.backend.database.DbUserPermission +import de.kif.backend.database.dbQuery +import io.ktor.auth.Principal +import org.jetbrains.exposed.sql.* +import org.mindrot.jbcrypt.BCrypt + +class User( + var id: Int = -1, + var username: String = "", + private var password: String = "" +) : Principal { + var permissions: Set = emptySet() + + fun checkPassword(password: String): Boolean { + return BCrypt.checkpw(password, this.password) + } + + fun hashPassword(newPassword: String) { + password = BCrypt.hashpw(newPassword, BCrypt.gensalt()) + } + + fun checkPermission(permission: Permission): Boolean { + return permission in permissions || Permission.ADMIN in permissions + } + + suspend fun loadPermissions() = dbQuery { + permissions = DbUserPermission.slice(DbUserPermission.permission).select { + DbUserPermission.userId eq id + }.map { it[DbUserPermission.permission] }.toSet() + } + + suspend fun save() { + if (id < 0) { + dbQuery { + val newId = DbUser.insert { + it[username] = this@User.username + it[password] = this@User.password + }[DbUser.userId]!! + this@User.id = newId + + for (permission in permissions) { + DbUserPermission.insert { + it[userId] = newId + it[this.permission] = permission + } + } + } + } else { + dbQuery { + DbUser.update({ DbUser.userId eq id }) { + it[username] = this@User.username + it[password] = this@User.password + } + + DbUserPermission.deleteWhere { DbUserPermission.userId eq id } + + for (permission in permissions) { + DbUserPermission.insert { + it[userId] = id + it[this.permission] = permission + } + } + } + } + } + + suspend fun delete() { + val id = id + if (id >= 0) { + dbQuery { + DbUserPermission.deleteWhere { DbUserPermission.userId eq id } + DbUser.deleteWhere { DbUser.userId eq id } + } + } + } + + companion object { + suspend fun create(username: String, password: String, permissions: Set) { + val user = User(username = username) + user.hashPassword(password) + user.permissions = permissions + user.save() + } + + suspend fun find(username: String): User? = dbQuery { + val result = DbUser.select { DbUser.username eq username }.firstOrNull() ?: return@dbQuery null + User(result[DbUser.userId], result[DbUser.username], result[DbUser.password]) + + }?.apply { loadPermissions() } + + suspend fun get(userId: Int): User? = dbQuery { + val result = DbUser.select { DbUser.userId eq userId }.firstOrNull() ?: return@dbQuery null + User(result[DbUser.userId], result[DbUser.username], result[DbUser.password]) + }?.apply { loadPermissions() } + + suspend fun list(): List = dbQuery { + val query = DbUser.selectAll() + query.map { result -> + User(result[DbUser.userId], result[DbUser.username], result[DbUser.password]) + } + }.onEach { it.loadPermissions() } + + suspend fun exists(): Boolean = dbQuery { + DbUser.selectAll().count() == 0 + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/WorkGroup.kt b/src/jvmMain/kotlin/de/kif/backend/model/WorkGroup.kt new file mode 100644 index 0000000..0e30f88 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/WorkGroup.kt @@ -0,0 +1,109 @@ +package de.kif.backend.model + +import de.kif.backend.database.DbWorkGroup +import de.kif.backend.database.DbWorkGroupConstraint +import de.kif.backend.database.dbQuery +import org.jetbrains.exposed.sql.* + +class WorkGroup( + var id: Int = -1, + var name: String = "", + var interested: Int = 0, + var trackId: Int? = null, + var projector: Boolean = false, + var resolution: Boolean = false, + var length: Int = 0, + var start: Long? = null, + var end: Long? = null +) { + var constraints: Set = emptySet() + + suspend fun save() { + if (id < 0) { + dbQuery { + val newId = DbWorkGroup.insert { + it[name] = this@WorkGroup.name + it[interested] = this@WorkGroup.interested + it[trackId] = this@WorkGroup.trackId + it[projector] = this@WorkGroup.projector + it[resolution] = this@WorkGroup.resolution + it[length] = this@WorkGroup.length + it[start] = this@WorkGroup.start + it[end] = this@WorkGroup.end + }[DbWorkGroup.id]!! + this@WorkGroup.id = newId + } + for (constraint in constraints) { + constraint.save(this@WorkGroup.id) + } + } else { + dbQuery { + DbWorkGroup.update({ DbWorkGroup.id eq id }) { + it[name] = this@WorkGroup.name + it[interested] = this@WorkGroup.interested + it[trackId] = this@WorkGroup.trackId + it[projector] = this@WorkGroup.projector + it[resolution] = this@WorkGroup.resolution + it[length] = this@WorkGroup.length + it[start] = this@WorkGroup.start + it[end] = this@WorkGroup.end + } + + DbWorkGroupConstraint.deleteWhere { DbWorkGroupConstraint.workGroupId eq id } + } + for (constraint in constraints) { + constraint.save(this@WorkGroup.id) + } + } + } + + suspend fun delete() { + val id = id + if (id >= 0) { + dbQuery { + DbWorkGroupConstraint.deleteWhere { DbWorkGroupConstraint.workGroupId eq id } + DbWorkGroup.deleteWhere { DbWorkGroup.id eq id } + } + } + } + + suspend fun loadConstraints() { + if (id >= 0) { + constraints = WorkGroupConstraint.get(id) + } + } + + companion object { + suspend fun get(workGroupId: Int): WorkGroup? = dbQuery { + val result = DbWorkGroup.select { DbWorkGroup.id eq workGroupId }.firstOrNull() ?: return@dbQuery null + WorkGroup( + result[DbWorkGroup.id], + result[DbWorkGroup.name], + result[DbWorkGroup.interested], + result[DbWorkGroup.trackId], + result[DbWorkGroup.projector], + result[DbWorkGroup.resolution], + result[DbWorkGroup.length], + result[DbWorkGroup.start], + result[DbWorkGroup.end] + ) + }?.apply { loadConstraints() } + + suspend fun list(): List = dbQuery { + val query = DbWorkGroup.selectAll() + query.map { result -> + WorkGroup( + result[DbWorkGroup.id], + result[DbWorkGroup.name], + result[DbWorkGroup.interested], + result[DbWorkGroup.trackId], + result[DbWorkGroup.projector], + result[DbWorkGroup.resolution], + result[DbWorkGroup.length], + result[DbWorkGroup.start], + result[DbWorkGroup.end] + ) + } + }.onEach { it.loadConstraints() } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/model/WorkGroupConstraint.kt b/src/jvmMain/kotlin/de/kif/backend/model/WorkGroupConstraint.kt new file mode 100644 index 0000000..ab7dde5 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/model/WorkGroupConstraint.kt @@ -0,0 +1,132 @@ +package de.kif.backend.model + +import de.kif.backend.database.DbConstraintType +import de.kif.backend.database.DbWorkGroupConstraint +import de.kif.backend.database.dbQuery +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.update + +sealed class WorkGroupConstraint( + var id: Int = -1 +) { + + abstract suspend fun save(workGroupId: Int) + + suspend fun delete() { + val id = id + if (id >= 0) { + dbQuery { + DbWorkGroupConstraint.deleteWhere { DbWorkGroupConstraint.id eq id } + } + } + } + + class BeginOnDay( + var time: Long = 0, + var day: Int = 0 + ) : WorkGroupConstraint() { + override suspend fun save(workGroupId: Int) { + if (id < 0) { + dbQuery { + val newId = DbWorkGroupConstraint.insert { + it[this@insert.workGroupId] = workGroupId + it[type] = DbConstraintType.BEGIN + it[time] = this@BeginOnDay.time + it[day] = this@BeginOnDay.day + }[DbWorkGroupConstraint.id]!! + this@BeginOnDay.id = newId + } + } else { + dbQuery { + DbWorkGroupConstraint.update({ DbWorkGroupConstraint.id eq id }) { + it[this@update.workGroupId] = workGroupId + it[type] = DbConstraintType.BEGIN + it[time] = this@BeginOnDay.time + it[day] = this@BeginOnDay.day + } + } + } + } + } + + class EndOnDay( + var time: Long = 0, + var day: Int = 0 + ) : WorkGroupConstraint() { + override suspend fun save(workGroupId: Int) { + if (id < 0) { + dbQuery { + val newId = DbWorkGroupConstraint.insert { + it[this@insert.workGroupId] = workGroupId + it[type] = DbConstraintType.END + it[time] = this@EndOnDay.time + it[day] = this@EndOnDay.day + }[DbWorkGroupConstraint.id]!! + this@EndOnDay.id = newId + } + } else { + dbQuery { + DbWorkGroupConstraint.update({ DbWorkGroupConstraint.id eq id }) { + it[this@update.workGroupId] = workGroupId + it[type] = DbConstraintType.END + it[time] = this@EndOnDay.time + it[day] = this@EndOnDay.day + } + } + } + } + } + + class BlockedOnDay( + var time: Long = 0, + var duration: Int = 0, + var day: Int = 0 + ) : WorkGroupConstraint() { + + override suspend fun save(workGroupId: Int) { + if (id < 0) { + dbQuery { + val newId = DbWorkGroupConstraint.insert { + it[this@insert.workGroupId] = workGroupId + it[type] = DbConstraintType.BLOCKED + it[time] = this@BlockedOnDay.time + it[duration] = this@BlockedOnDay.duration + it[day] = this@BlockedOnDay.day + }[DbWorkGroupConstraint.id]!! + this@BlockedOnDay.id = newId + } + } else { + dbQuery { + DbWorkGroupConstraint.update({ DbWorkGroupConstraint.id eq id }) { + it[this@update.workGroupId] = workGroupId + it[type] = DbConstraintType.BLOCKED + it[time] = this@BlockedOnDay.time + it[duration] = this@BlockedOnDay.duration + it[day] = this@BlockedOnDay.day + } + } + } + } + } + + companion object { + suspend fun get(workGroupId: Int): Set = dbQuery { + val result = DbWorkGroupConstraint.select { DbWorkGroupConstraint.workGroupId eq workGroupId } + result.map { + val id = it[DbWorkGroupConstraint.id] + val type = it[DbWorkGroupConstraint.type] + val time = it[DbWorkGroupConstraint.time] + val duration = it[DbWorkGroupConstraint.duration] + val day = it[DbWorkGroupConstraint.day] + + when (type) { + DbConstraintType.BEGIN -> WorkGroupConstraint.BeginOnDay(time, day) + DbConstraintType.END -> WorkGroupConstraint.EndOnDay(time, day) + DbConstraintType.BLOCKED -> WorkGroupConstraint.BlockedOnDay(time, duration, day) + }.also { it.id = id } + }.toSet() + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Account.kt b/src/jvmMain/kotlin/de/kif/backend/route/Account.kt new file mode 100644 index 0000000..ee8ad87 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/Account.kt @@ -0,0 +1,46 @@ +package de.kif.backend.route + +import de.kif.backend.LocationAccount +import de.kif.backend.LocationLogin +import de.kif.backend.PortalSession +import de.kif.backend.view.MainTemplate +import de.kif.backend.view.MenuTemplate +import io.ktor.application.call +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.sessions.get +import io.ktor.sessions.sessions +import kotlinx.html.* + +fun Route.account() { + get { + val user = call.sessions.get()?.getUser(call) + if (user == null) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.ACCOUNT + } + content { + h1 { +"Account" } + div { + +"Welcome ${user.username}!" + br {} + +"You have the following rights: ${user.permissions}" + br {} + a("/logout") { + button(classes="form-btn") { + +"Logout" + } + } + } + } + } + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Calendar.kt b/src/jvmMain/kotlin/de/kif/backend/route/Calendar.kt new file mode 100644 index 0000000..00ec006 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/Calendar.kt @@ -0,0 +1,28 @@ +package de.kif.backend.route + +import de.kif.backend.LocationCalendar +import de.kif.backend.PortalSession +import de.kif.backend.view.MainTemplate +import de.kif.backend.view.MenuTemplate +import io.ktor.application.call +import io.ktor.html.respondHtmlTemplate +import io.ktor.locations.get +import io.ktor.routing.Route +import io.ktor.sessions.get +import io.ktor.sessions.sessions +import kotlinx.html.h1 + +fun Route.calendar() { + get { + val user = call.sessions.get()?.getUser(call) + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.CALENDAR + } + content { + h1 { +"Calendar" } + } + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Dashboard.kt b/src/jvmMain/kotlin/de/kif/backend/route/Dashboard.kt new file mode 100644 index 0000000..a71934a --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/Dashboard.kt @@ -0,0 +1,28 @@ +package de.kif.backend.route + +import de.kif.backend.LocationDashboard +import de.kif.backend.PortalSession +import de.kif.backend.view.MainTemplate +import de.kif.backend.view.MenuTemplate +import io.ktor.application.call +import io.ktor.html.respondHtmlTemplate +import io.ktor.locations.get +import io.ktor.routing.Route +import io.ktor.sessions.get +import io.ktor.sessions.sessions +import kotlinx.html.h1 + +fun Route.dashboard() { + get { + val user = call.sessions.get()?.getUser(call) + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.DASHBOARD + } + content { + h1 { +"Dashboard" } + } + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Login.kt b/src/jvmMain/kotlin/de/kif/backend/route/Login.kt new file mode 100644 index 0000000..7f4533a --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/Login.kt @@ -0,0 +1,102 @@ +package de.kif.backend.route + +import de.kif.backend.LocationLogin +import de.kif.backend.LocationLogout +import de.kif.backend.PortalSession +import de.kif.backend.model.User +import de.kif.backend.view.MainTemplate +import io.ktor.application.call +import io.ktor.auth.authenticate +import io.ktor.auth.principal +import io.ktor.html.respondHtmlTemplate +import io.ktor.locations.location +import io.ktor.response.respondRedirect +import io.ktor.routing.Route +import io.ktor.routing.get +import io.ktor.routing.post +import io.ktor.sessions.clear +import io.ktor.sessions.get +import io.ktor.sessions.sessions +import io.ktor.sessions.set +import kotlinx.html.* + +fun Route.login() { + location { + authenticate { + post { + val principal = call.principal() ?: return@post + call.sessions.set(PortalSession(principal.id, principal.username)) + call.respondRedirect(call.parameters[LocationLogin::next.name] ?: "/") + } + } + + get { + val needLogin = call.sessions.get() == null + if (needLogin) { + call.respondHtmlTemplate(MainTemplate()) { + content { + div { + div { + br { } + form("/login", method = FormMethod.post) { + div("form-group") { + label { + htmlFor = LocationLogin::username.name + +"Username" + } + input( + name = LocationLogin::username.name, + classes = "form-control" + ) { + id = LocationLogin::username.name + placeholder = "Username" + } + } + div("form-group") { + label { + htmlFor = LocationLogin::password.name + +"Password" + } + input( + name = LocationLogin::password.name, + classes = "form-control", + type = InputType.password + ) { + id = LocationLogin::password.name + placeholder = "Password" + } + } + input( + name = LocationLogin::next.name, + type = InputType.hidden + ) { + value = call.parameters[LocationLogin::next.name] ?: "/" + } + button(type = ButtonType.submit, classes = "form-btn btn-primary") { + +"Login" + } + } + + if ("error" in call.parameters) { + br { } + div("alert alert-danger") { + +"Username or password incorrect!" + } + } + } + } + } + } + } else { + call.respondRedirect(call.parameters[LocationLogin::next.name] ?: "/") + } + } + } + + location { + get { + call.sessions.clear() + call.respondRedirect("/") + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Person.kt b/src/jvmMain/kotlin/de/kif/backend/route/Person.kt new file mode 100644 index 0000000..77803d3 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/Person.kt @@ -0,0 +1,258 @@ +package de.kif.backend.route + +import de.kif.backend.LocationLogin +import de.kif.backend.LocationPerson +import de.kif.backend.PortalSession +import de.kif.backend.model.Permission +import de.kif.backend.model.Person +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 kotlinx.html.* + +fun Route.person() { + get { param -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.PERSON)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val list = Person.list() + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.PERSON + } + content { + h1 { +"Persons" } + insert(TableTemplate()) { + searchValue = param.search + + action { + a("/person/new") { + button(classes="form-btn btn-primary") { + +"Add person" + } + } + } + + header { + th { + +"First name" + } + th { + +"Last name" + } + th(classes = "action") { + +"Action" + } + } + + for (u in list) { + if (Search.match(param.search, u.firstName, u.lastName)) { + entry { + attributes["data-search"] = Search.pack(u.firstName, u.lastName) + td { + +u.firstName + } + td { + +u.lastName + } + td(classes = "action") { + a("/person/${u.id}") { + i("material-icons") { +"edit" } + } + } + } + } + } + } + } + } + } + } + + get { personId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.PERSON)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val editPerson = Person.get(personId.id) ?: return@get + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.PERSON + } + content { + h1 { +"Edit person" } + form(method = FormMethod.post) { + div("form-group") { + label { + htmlFor = "first-name" + +"First name" + } + input( + name = "first-name", + classes = "form-control" + ) { + id = "first-name" + placeholder = "First name" + value = editPerson.firstName + } + } + div("form-group") { + label { + htmlFor = "last-name" + +"Last name" + } + input( + name = "last-name", + classes = "form-control" + ) { + id = "last-name" + placeholder = "Last name" + value = editPerson.lastName + } + } + + div("form-group") { + a("/person") { + button(classes = "form-btn") { + +"Cancel" + } + } + button(type = ButtonType.submit, classes = "form-btn btn-primary") { + +"Save" + } + } + } + a("/person/${editPerson.id}/delete") { + button(classes = "form-btn btn-danger") { + +"Delete" + } + } + } + } + } + } + + post { personId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.PERSON)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val params = call.receiveParameters().toMap().mapValues { (_, list) -> + list.firstOrNull() + } + val editPerson = Person.get(personId.id) ?: return@post + + params["first-name"]?.let { editPerson.firstName = it } + params["last-name"]?.let { editPerson.lastName = it } + + editPerson.save() + + call.respondRedirect("/person") + } + } + + get { + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.PERSON)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.PERSON + } + content { + h1 { +"Create person" } + form(method = FormMethod.post) { + div("form-group") { + label { + htmlFor = "first-name" + +"First name" + } + input( + name = "first-name", + classes = "form-control" + ) { + id = "first-name" + placeholder = "First name" + value = "" + } + } + div("form-group") { + label { + htmlFor = "last-name" + +"Last name" + } + input( + name = "last-name", + classes = "form-control" + ) { + id = "last-name" + placeholder = "Last name" + value = "" + } + } + + div("form-group") { + a("/person") { + 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.PERSON)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val params = call.receiveParameters().toMap().mapValues { (_, list) -> + list.firstOrNull() + } + + val firstName = params["first-name"] ?: return@post + val lastName = params["last-name"] ?: return@post + + Person(firstName = firstName, lastName = lastName).save() + + call.respondRedirect("/person") + } + } + + get { personId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.PERSON)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val deletePerson = Person.get(personId.id) ?: return@get + + deletePerson.delete() + + call.respondRedirect("/person") + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Room.kt b/src/jvmMain/kotlin/de/kif/backend/route/Room.kt new file mode 100644 index 0000000..e8a0532 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/Room.kt @@ -0,0 +1,308 @@ +package de.kif.backend.route + +import de.kif.backend.LocationLogin +import de.kif.backend.LocationRoom +import de.kif.backend.PortalSession +import de.kif.backend.model.Permission +import de.kif.backend.model.Room +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 kotlinx.html.* + +fun Route.room() { + get { param -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.ROOM)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val list = Room.list() + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.ROOM + } + content { + h1 { +"Rooms" } + insert(TableTemplate()) { + searchValue = param.search + + action { + a("/room/new") { + button(classes="form-btn btn-primary") { + +"Add room" + } + } + } + + header { + th { + +"Name" + } + th { + +"Places" + } + th { + +"Projector" + } + 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.places.toString() + } + td { + +u.projector.toString() + } + td(classes = "action") { + a("/room/${u.id}") { + i("material-icons") { +"edit" } + } + } + } + } + } + } + } + } + } + } + + get { roomId -> + val user = call.sessions.get()?.getUser(call) + 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 + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.ROOM + } + content { + h1 { +"Edit room" } + form(method = FormMethod.post) { + div("form-group") { + label { + htmlFor = "name" + +"Name" + } + input( + name = "name", + classes = "form-control" + ) { + id = "name" + placeholder = "Name" + value = editRoom.name + } + } + div("form-group") { + label { + htmlFor = "places" + +"Places" + } + input( + name = "places", + classes = "form-control", + type = InputType.number + ) { + id = "places" + placeholder = "Places" + value = editRoom.places.toString() + + min = "0" + max = "1337" + } + } + + div("form-switch-group") { + div("form-group form-switch") { + input( + name = "projector", + classes = "form-control", + type = InputType.checkBox + ) { + id = "projector" + checked = editRoom.projector + } + label { + htmlFor = "projector" + +"Projector" + } + } + } + + div("form-group") { + a("/room") { + button(classes = "form-btn") { + +"Cancel" + } + } + button(type = ButtonType.submit, classes = "form-btn btn-primary") { + +"Save" + } + } + } + a("/room/${editRoom.id}/delete") { + button(classes = "form-btn btn-danger") { + +"Delete" + } + } + } + } + } + } + + post { roomId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.ROOM)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val params = call.receiveParameters().toMap().mapValues { (_, list) -> + list.firstOrNull() + } + val editRoom = Room.get(roomId.id) ?: return@post + + params["name"]?.let { editRoom.name = it } + params["places"]?.let { editRoom.places = it.toIntOrNull() ?: 0 } + params["projector"]?.let { editRoom.projector = it == "on" } + + editRoom.save() + + call.respondRedirect("/room") + } + } + + get { + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.ROOM)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.ROOM + } + content { + h1 { +"Create room" } + 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") { + label { + htmlFor = "places" + +"Places" + } + input( + name = "places", + classes = "form-control", + type = InputType.number + ) { + id = "places" + placeholder = "Places" + value = "0" + + min = "0" + max = "1337" + } + } + + div("form-switch-group") { + div("form-group form-switch") { + input( + name = "projector", + classes = "form-control", + type = InputType.checkBox + ) { + id = "projector" + checked = false + } + label { + htmlFor = "projector" + +"Projector" + } + } + } + + div("form-group") { + a("/room") { + 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.ROOM)) { + 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 places = (params["places"] ?: return@post).toIntOrNull() ?: 0 + val projector = params["projector"] == "on" + + Room(name = name, places = places, projector = projector).save() + + call.respondRedirect("/room") + } + } + + get { roomId -> + val user = call.sessions.get()?.getUser(call) + 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 + + deleteRoom.delete() + + call.respondRedirect("/room") + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/Setup.kt b/src/jvmMain/kotlin/de/kif/backend/route/Setup.kt new file mode 100644 index 0000000..73029c7 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/Setup.kt @@ -0,0 +1,101 @@ +package de.kif.backend.route + +import de.kif.backend.LocationLogin +import de.kif.backend.database.DbUser +import de.kif.backend.model.Permission +import de.kif.backend.model.User +import de.kif.backend.view.MainTemplate +import io.ktor.application.call +import io.ktor.html.respondHtmlTemplate +import io.ktor.request.receiveParameters +import io.ktor.response.respondRedirect +import io.ktor.routing.Route +import io.ktor.routing.get +import io.ktor.routing.post +import io.ktor.routing.route +import kotlinx.html.* +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction + +fun Route.setup() { + route("/") { + get { + call.respondHtmlTemplate(MainTemplate()) { + transaction { + val firstStart = DbUser.selectAll().count() == 0 + + menuTemplate { + setup = true + } + + if (firstStart) { + content { + div { + h1 { +"Create account" } + form("/", method = FormMethod.post) { + div("form-group") { + label { + htmlFor = LocationLogin::username.name + +"Username" + } + input( + name = LocationLogin::username.name, + classes = "form-control" + ) { + id = LocationLogin::username.name + placeholder = "Username" + } + } + div("form-group") { + label { + htmlFor = LocationLogin::password.name + +"Password" + } + input( + name = LocationLogin::password.name, + classes = "form-control", + type = InputType.password + ) { + id = LocationLogin::password.name + placeholder = "Password" + } + } + button(type = ButtonType.submit, classes = "btn btn-primary") { + +"Create" + } + } + } + } + } else { + content { + div { + h1 { +"Setup complete" } + p { + +"Please restart the server!" + } + } + } + } + } + } + } + + post { + val parameters = call.receiveParameters() + val username = parameters[LocationLogin::username.name] + val password = parameters[LocationLogin::password.name] + + if (username == null || password == null) { + call.respondRedirect("/") + return@post + } + + User.create(username, password, Permission.values().toSet()) + call.respondRedirect("/") + } + } + + get("*") { + call.respondRedirect("/") + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/User.kt b/src/jvmMain/kotlin/de/kif/backend/route/User.kt new file mode 100644 index 0000000..4dc6b4c --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/User.kt @@ -0,0 +1,313 @@ +package de.kif.backend.route + +import de.kif.backend.LocationLogin +import de.kif.backend.LocationUser +import de.kif.backend.PortalSession +import de.kif.backend.model.Permission +import de.kif.backend.model.User +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 kotlinx.html.* + +fun Route.user() { + get { param -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.USER)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val list = User.list() + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.USER + } + content { + h1 { +"Users" } + insert(TableTemplate()) { + searchValue = param.search + + action { + a("/user/new") { + button(classes="form-btn btn-primary") { + +"Add user" + } + } + } + + header { + th { + +"Username" + } + th { + +"Permissions" + } + th(classes = "action") { + +"Action" + } + } + + for (u in list) { + if (Search.match(param.search, u.username)) { + entry { + attributes["data-search"] = Search.pack(u.username) + td { + +u.username + } + td { + +u.permissions.joinToString(", ") { it.toString().toLowerCase() } + } + td(classes = "action") { + a("/user/${u.id}") { + i("material-icons") { +"edit" } + } + } + } + } + } + } + } + } + } + } + + get { userId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.USER)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val editUser = User.get(userId.id) ?: return@get + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.USER + } + content { + h1 { +"Edit user" } + form(method = FormMethod.post) { + div("form-group") { + label { + htmlFor = "username" + +"Username" + } + input( + name = "username", + classes = "form-control" + ) { + id = "username" + placeholder = "Username" + value = editUser.username + } + } + + div("form-switch-group") { + for (permission in Permission.values()) { + val name = permission.toString().toLowerCase() + div("form-group form-switch") { + input( + name = "permission-$name", + classes = "form-control", + type = InputType.checkBox + ) { + id = "permission-$name" + checked = permission in editUser.permissions + + if (!user.checkPermission(permission)) { + readonly = true + } + } + label { + htmlFor = "permission-$name" + +name.capitalize() + } + } + } + } + + div("form-group") { + a("/user") { + button(classes = "form-btn") { + +"Cancel" + } + } + button(type = ButtonType.submit, classes = "form-btn btn-primary") { + +"Save" + } + } + } + a("/user/${editUser.id}/delete") { + button(classes = "form-btn btn-danger") { + +"Delete" + } + } + } + } + } + } + + post { userId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.USER)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val params = call.receiveParameters().toMap().mapValues { (_, list) -> + list.firstOrNull() + } + val editUser = User.get(userId.id) ?: return@post + + params["username"]?.let { editUser.username = it } + + for (permission in Permission.values()) { + val name = permission.toString().toLowerCase() + if (user.checkPermission(permission)) { + if (params["permission-$name"] == "on") { + editUser.permissions += permission + } else { + editUser.permissions -= permission + } + } + } + editUser.save() + + call.respondRedirect("/user") + } + } + + get { + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.USER)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.USER + } + content { + h1 { +"Create user" } + form(method = FormMethod.post) { + div("form-group") { + label { + htmlFor = "username" + +"Username" + } + input( + name = "username", + classes = "form-control" + ) { + id = "username" + placeholder = "Username" + value = "" + } + } + div("form-group") { + label { + htmlFor = "password" + +"Password" + } + input( + name = "password", + classes = "form-control", + type = InputType.password + ) { + id = "password" + placeholder = "Password" + value = "" + } + } + + div("form-switch-group") { + for (permission in Permission.values()) { + val name = permission.toString().toLowerCase() + div("form-group form-switch") { + input( + name = "permission-$name", + classes = "form-control", + type = InputType.checkBox + ) { + id = "permission-$name" + checked = false + + if (!user.checkPermission(permission)) { + readonly = true + } + } + label { + htmlFor = "permission-$name" + +name.capitalize() + } + } + } + } + + div("form-group") { + a("/user") { + 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.USER)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val params = call.receiveParameters().toMap().mapValues { (_, list) -> + list.firstOrNull() + } + + val username = params["username"] ?: return@post + val password = params["password"] ?: return@post + + val permissions = Permission.values().mapNotNull { permission -> + if (user.checkPermission(permission) && params["permission-${permission.toString().toLowerCase()}"] == "on") + permission + else + null + }.toSet() + + User.create(username, password, permissions) + + call.respondRedirect("/user") + } + } + + get { userId -> + val user = call.sessions.get()?.getUser(call) + if (user == null || !user.checkPermission(Permission.USER)) { + call.respondRedirect("/login?${LocationLogin::next.name}=${call.request.path()}}") + } else { + val deleteUser = User.get(userId.id) ?: return@get + + if (user.checkPermission(Permission.USER) && + (Permission.ADMIN !in deleteUser.permissions || Permission.ADMIN in user.permissions) + ) { + deleteUser.delete() + } + + call.respondRedirect("/user") + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt b/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt new file mode 100644 index 0000000..b24f4df --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/route/WorkGroup.kt @@ -0,0 +1,433 @@ +package de.kif.backend.route + +import de.kif.backend.LocationLogin +import de.kif.backend.LocationWorkGroup +import de.kif.backend.PortalSession +import de.kif.backend.model.Permission +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.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 kotlinx.html.* + +fun Route.workGroup() { + 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 = WorkGroup.list() + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.WORK_GROUP + } + content { + h1 { +"Work groups" } + insert(TableTemplate()) { + searchValue = param.search + + action { + a("/workgroup/new") { + button(classes = "form-btn btn-primary") { + +"Add work group" + } + } + } + + header { + th { + +"Name" + } + th { + +"Interested" + } + th { + +"Track" + } + th { + +"Projector" + } + th { + +"Resolution" + } + th { + +"Length" + } + 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.interested.toString() + } + td { + +u.trackId.toString() + } + td { + +u.projector.toString() + } + td { + +u.resolution.toString() + } + td { + +u.length.toString() + } + td(classes = "action") { + a("/workgroup/${u.id}") { + i("material-icons") { +"edit" } + } + } + } + } + } + } + } + } + } + } + + get { workGroupId -> + 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 editWorkGroup = WorkGroup.get(workGroupId.id) ?: return@get + call.respondHtmlTemplate(MainTemplate()) { + menuTemplate { + this.user = user + active = MenuTemplate.Tab.WORK_GROUP + } + content { + h1 { +"Edit work group" } + form(method = FormMethod.post) { + div("form-group") { + label { + htmlFor = "name" + +"Name" + } + input( + name = "name", + classes = "form-control" + ) { + id = "name" + placeholder = "Name" + value = editWorkGroup.name + } + } + div("form-group") { + label { + htmlFor = "interested" + +"Interested" + } + input( + name = "interested", + classes = "form-control", + type = InputType.number + ) { + id = "interested" + value = editWorkGroup.interested.toString() + + min = "0" + max = "1337" + } + } + div("form-group") { + label { + htmlFor = "track" + +"Track" + } + input( + name = "track", + classes = "form-control", + type = InputType.number + ) { + id = "track" + value = editWorkGroup.trackId.toString() + + min = "0" + max = "1337" + } + } + div("form-group") { + label { + htmlFor = "length" + +"Length" + } + input( + name = "length", + classes = "form-control", + type = InputType.number + ) { + id = "length" + value = editWorkGroup.length.toString() + + min = "0" + max = "1440" + } + } + + div("form-switch-group") { + div("form-group form-switch") { + input( + name = "projector", + classes = "form-control", + type = InputType.checkBox + ) { + id = "projector" + checked = editWorkGroup.projector + } + label { + htmlFor = "projector" + +"Projector" + } + } + div("form-group form-switch") { + input( + name = "resolution", + classes = "form-control", + type = InputType.checkBox + ) { + id = "resolution" + checked = editWorkGroup.resolution + } + label { + htmlFor = "resolution" + +"Resolution" + } + } + } + + div("form-group") { + a("/workgroup") { + button(classes = "form-btn") { + +"Cancel" + } + } + button(type = ButtonType.submit, classes = "form-btn btn-primary") { + +"Save" + } + } + } + a("/workgroup/${editWorkGroup.id}/delete") { + button(classes = "form-btn btn-danger") { + +"Delete" + } + } + } + } + } + } + + post { workGroupId -> + 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 editWorkGroup = WorkGroup.get(workGroupId.id) ?: return@post + + params["name"]?.let { editWorkGroup.name = it } + params["interested"]?.toIntOrNull()?.let { editWorkGroup.interested = it } + params["track"]?.toIntOrNull()?.let { editWorkGroup.trackId = it } + params["projector"]?.let { editWorkGroup.projector = it == "on" } + params["resolution"]?.let { editWorkGroup.resolution = it == "on" } + params["length"]?.toIntOrNull()?.let { editWorkGroup.length = it } + + editWorkGroup.save() + + call.respondRedirect("/workgroup") + } + } + + 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 work group" } + 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") { + label { + htmlFor = "interested" + +"Interested" + } + input( + name = "interested", + classes = "form-control", + type = InputType.number + ) { + id = "interested" + value = "0" + + min = "0" + max = "1337" + } + } + div("form-group") { + label { + htmlFor = "track" + +"Track" + } + input( + name = "track", + classes = "form-control", + type = InputType.number + ) { + id = "track" + value = "" + + min = "0" + max = "1337" + } + } + div("form-group") { + label { + htmlFor = "length" + +"Length" + } + input( + name = "length", + classes = "form-control", + type = InputType.number + ) { + id = "length" + value = "0" + + min = "0" + max = "1440" + } + } + + div("form-switch-group") { + div("form-group form-switch") { + input( + name = "projector", + classes = "form-control", + type = InputType.checkBox + ) { + id = "projector" + checked = false + } + label { + htmlFor = "projector" + +"Projector" + } + } + div("form-group form-switch") { + input( + name = "resolution", + classes = "form-control", + type = InputType.checkBox + ) { + id = "resolution" + checked = false + } + label { + htmlFor = "resolution" + +"Resolution" + } + } + } + + div("form-group") { + a("/workgroup") { + 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 interested = (params["interested"] ?: return@post).toIntOrNull() ?: 0 + val trackId = (params["track"] ?: return@post).toIntOrNull() + val projector = params["projector"] == "on" + val resolution = params["resolution"] == "on" + val length = (params["length"] ?: return@post).toIntOrNull() ?: 0 + + WorkGroup( + name = name, + interested = interested, + trackId = trackId, + projector = projector, + resolution = resolution, + length = length + ).save() + + call.respondRedirect("/workgroup") + } + } + + get { workGroupId -> + 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 deleteWorkGroup = WorkGroup.get(workGroupId.id) ?: return@get + + deleteWorkGroup.delete() + + call.respondRedirect("/workgroup") + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/util/Search.kt b/src/jvmMain/kotlin/de/kif/backend/util/Search.kt new file mode 100644 index 0000000..5467a32 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/util/Search.kt @@ -0,0 +1,29 @@ +package de.kif.backend.util + +object Search { + private fun permute(list: List): List> { + if (list.size <= 1) return listOf(list) + + val perm = mutableListOf>() + + for (elem in list) { + val p = permute(list - elem) + + for (x in p) { + perm += x + elem + } + } + + return perm + } + + fun match(search: String, vararg params: String): Boolean { + val s = search.toLowerCase().replace(" ", "") + + return permute(params.map { it.toLowerCase().replace(" ", "") }).any { + it.joinToString("").contains(s) + } + } + + fun pack(vararg params: String): String = params.joinToString("|") { it.toLowerCase() } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt b/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt new file mode 100644 index 0000000..abff722 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/view/MainTemplate.kt @@ -0,0 +1,43 @@ +package de.kif.backend.view + +import io.ktor.html.Placeholder +import io.ktor.html.Template +import io.ktor.html.TemplatePlaceholder +import io.ktor.html.insert +import kotlinx.html.* + +class MainTemplate : Template { + val content = Placeholder() + val menuTemplate =TemplatePlaceholder() + + override fun HTML.apply() { + head { + meta(charset = "utf-8") + meta(name="viewport",content = "width=device-width, initial-scale=1.0") + + title("KIF Portal") + + link(href = "/static/external/material-icons.css", type = LinkType.textCss, rel = LinkRel.stylesheet) + link(href = "/static/external/font/Montserrat.css", type = LinkType.textCss, rel = LinkRel.stylesheet) + link(href = "https://fonts.googleapis.com/css?family=Bungee|Oswald", type = LinkType.textCss, rel = LinkRel.stylesheet) + link(href = "/static/style/style.css", type = LinkType.textCss, rel = LinkRel.stylesheet) + + script(src = "/static/require.min.js") {} + + /*script { + unsafe { + +"require.config({baseUrl: '/static'});\n" + +("require([${Resources.jsModules}]);\n") + } + }*/ + } + body { + insert(MenuTemplate(), menuTemplate) + div("container") { + div("main") { + insert(content) + } + } + } + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/view/MenuTemplate.kt b/src/jvmMain/kotlin/de/kif/backend/view/MenuTemplate.kt new file mode 100644 index 0000000..89e91a0 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/view/MenuTemplate.kt @@ -0,0 +1,77 @@ +package de.kif.backend.view + +import de.kif.backend.model.Permission +import de.kif.backend.model.User +import io.ktor.html.Template +import kotlinx.html.* + +class MenuTemplate() : Template { + + var setup = false + var active: Tab = Tab.DASHBOARD + var user: User? = null + + override fun FlowContent.apply() { + nav("menu") { + div("container") { + div("menu-left") { + if (setup) { + a(classes = "active") { + +"Setup" + } + } else { + a("/", classes = if (active == Tab.DASHBOARD) "active" else null) { + +"Dashboard" + } + a("/calendar", classes = if (active == Tab.CALENDAR) "active" else null) { + +"Calendar" + } + } + } + if (!setup) { + div("menu-right") { + span("menu-icon") { + i("material-icons") { +"menu" } + } + val user = user + div("menu-content") { + if (user == null) { + a("/account", classes = if (active == Tab.ACCOUNT) "active" else null) { + +"Login" + } + } else { + if (user.checkPermission(Permission.WORK_GROUP)) { + a("/workgroup", classes = if (active == Tab.WORK_GROUP) "active" else null) { + +"Work groups" + } + } + if (user.checkPermission(Permission.ROOM)) { + a("/room", classes = if (active == Tab.ROOM) "active" else null) { + +"Rooms" + } + } + if (user.checkPermission(Permission.PERSON)) { + a("/person", classes = if (active == Tab.PERSON) "active" else null) { + +"Persons" + } + } + if (user.checkPermission(Permission.PERSON)) { + a("/user", classes = if (active == Tab.USER) "active" else null) { + +"Users" + } + } + a("/account", classes = if (active == Tab.ACCOUNT) "active" else null) { + +user.username + } + } + } + } + } + } + } + } + + enum class Tab { + DASHBOARD, CALENDAR, ACCOUNT, WORK_GROUP, ROOM, PERSON, USER + } +} diff --git a/src/jvmMain/kotlin/de/kif/backend/view/TableTemplate.kt b/src/jvmMain/kotlin/de/kif/backend/view/TableTemplate.kt new file mode 100644 index 0000000..2d76c41 --- /dev/null +++ b/src/jvmMain/kotlin/de/kif/backend/view/TableTemplate.kt @@ -0,0 +1,42 @@ +package de.kif.backend.view + +import io.ktor.html.* +import kotlinx.html.* + + +class TableTemplate() : Template { + + var searchValue = "" + + val action = Placeholder() + val header = Placeholder() + val entry = PlaceholderList() + + override fun FlowContent.apply() { + div("table-layout") { + form(classes = "form-group table-layout-search") { + input(InputType.search, name = "search", classes = "form-control") { + placeholder = "Search" + value = searchValue + } + button(type = ButtonType.submit, classes = "form-btn btn-search") { + i("material-icons") { +"search" } + } + } + div("table-layout-action") { + insert(action) + } + + } + table("table-layout-table") { + tr { + insert(header) + } + each(entry) { + tr { + insert(it) + } + } + } + } +} diff --git a/src/jvmMain/resources/logback.xml b/src/jvmMain/resources/logback.xml new file mode 100644 index 0000000..57a363c --- /dev/null +++ b/src/jvmMain/resources/logback.xml @@ -0,0 +1,15 @@ + + + + + + true + + %d{HH:mm:ss.SSS} [%thread] %highlight(%-5level) %cyan(%logger{20}) - %msg %n + + + + + + +