Separate views into a maven module

This commit is contained in:
2020-10-24 01:25:25 +02:00
parent 536c6e7b79
commit 8b8dbd6fe5
37 changed files with 255 additions and 227 deletions
@@ -0,0 +1,107 @@
package be.simplenotes.views
import be.simplenotes.types.LoggedInUser
import kotlinx.html.*
import kotlinx.html.ThScope.col
class BaseView(styles: String) : View(styles) {
fun renderHome(loggedInUser: LoggedInUser?) = renderPage(
title = "Home",
description = "A fast and simple note taking website",
loggedInUser = loggedInUser
) {
section("text-center my-2 p-2") {
h1("text-5xl casual") {
span("text-teal-300") { +"SimpleNotes " }
+"- access your notes anywhere"
}
}
div("container mx-auto flex flex-wrap justify-center content-center") {
div("md:order-1 order-2 flipped p-4 my-10 w-full md:w-1/2") {
attributes["aria-label"] = "demo"
div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Notes" }
span {
span("btn btn-teal pointer-events-none") { +"Trash (3)" }
span("ml-2 btn btn-green pointer-events-none") { +"New" }
}
}
form(classes = "md:space-x-2") {
id = "search"
input {
attributes["aria-label"] = "demo-search"
attributes["name"] = "search"
attributes["disabled"] = ""
attributes["value"] = "tag:\"demo\""
}
span {
id = "buttons"
button(type = ButtonType.button, classes = "btn btn-green pointer-events-none") {
attributes["disabled"] = ""
+"search"
}
span("btn btn-red pointer-events-none") { +"clear" }
}
}
div("overflow-x-auto") {
demoTable()
}
}
welcome()
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun DIV.demoTable() {
table {
id = "notes"
thead {
tr {
th(scope = col, classes = "w-1/2") { +"Title" }
th(scope = col, classes = "w-1/4") { +"Updated" }
th(scope = col, classes = "w-1/4") { +"Tags" }
}
}
tbody {
listOf(
Triple("Formula 1", "moments ago", arrayOf("#demo")),
Triple("Syntax highlighting", "2 hours ago", arrayOf("#features", "#demo")),
Triple("report", "5 days ago", arrayOf("#study", "#demo")),
).forEach { (title, ago, tags) ->
tr {
td { span("text-blue-200 font-semibold underline") { +title } }
td("text-center") { +ago }
td {
ul("inline flex flex-wrap justify-center") {
tags.forEach { tag ->
li("mx-2 my-1") { span("tag disabled") { +tag } }
}
}
}
}
}
}
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun DIV.welcome() {
div("w-full my-auto md:w-1/2 md:order-2 order-1 text-center") {
div("m-4 rounded-lg p-6") {
h2("text-3xl text-teal-400 underline") { +"Features:" }
ul("list-disc text-lg list-inside") {
li { +"Markdown support" }
li { +"Full text search" }
li { +"Structured search" }
li { +"Code highlighting" }
li { +"Fast and lightweight" }
li { +"No tracking" }
li { +"Works without javascript" }
li { +"Data export" }
}
}
}
}
}
@@ -0,0 +1,33 @@
package be.simplenotes.views
import be.simplenotes.views.components.Alert
import be.simplenotes.views.components.alert
import kotlinx.html.a
import kotlinx.html.div
class ErrorView(styles: String) : View(styles) {
enum class Type(val title: String) {
SqlTransientError("Database unavailable"),
NotFound("Not Found"),
Other("Error"),
}
fun error(errorType: Type) = renderPage(errorType.title, loggedInUser = null) {
div("container mx-auto p-4") {
when (errorType) {
Type.SqlTransientError -> alert(
Alert.Warning,
errorType.title,
"Please try again later",
multiline = true
)
Type.NotFound -> alert(Alert.Warning, errorType.title, "Page not found", multiline = true)
Type.Other -> alert(Alert.Warning, errorType.title)
}
div {
a(href = "/", classes = "btn btn-green") { +"Go back to the homepage" }
}
}
}
}
@@ -0,0 +1,198 @@
package be.simplenotes.views
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.components.noteListHeader
import be.simplenotes.types.PersistedNote
import be.simplenotes.types.PersistedNoteMetadata
import be.simplenotes.views.components.*
import io.konform.validation.ValidationError
import kotlinx.html.*
class NoteView(styles: String) : View(styles) {
fun noteEditor(
loggedInUser: LoggedInUser,
error: String? = null,
textarea: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = renderPage(title = "New note", loggedInUser = loggedInUser) {
div("container mx-auto p-4") {
error?.let { alert(Alert.Warning, error) }
validationErrors.forEach {
alert(Alert.Warning, it.dataPath.substringAfter('.') + ": " + it.message)
}
form(method = FormMethod.post) {
textArea {
attributes.also {
it["rows"] = "20"
it["id"] = "markdown"
it["name"] = "markdown"
it["aria-label"] = "markdown text area"
it["spellcheck"] = "false"
}
textarea?.let {
+it
} ?: +"""
|---
|title: ''
|tags: []
|---
|
""".trimMargin("|")
}
submitButton("Save")
}
}
}
fun notes(
loggedInUser: LoggedInUser,
notes: List<PersistedNoteMetadata>,
currentPage: Int,
numberOfPages: Int,
numberOfDeletedNotes: Int,
tag: String?,
) = renderPage(title = "Notes", loggedInUser = loggedInUser) {
div("container mx-auto p-4") {
noteListHeader(numberOfDeletedNotes)
if (notes.isNotEmpty())
noteTable(notes)
else
span {
if (numberOfPages > 1) +"You went too far"
else +"No notes yet"
}
if (numberOfPages > 1) pagination(currentPage, numberOfPages, tag)
}
}
fun search(
loggedInUser: LoggedInUser,
notes: List<PersistedNoteMetadata>,
query: String,
numberOfDeletedNotes: Int,
) = renderPage("Notes", loggedInUser = loggedInUser) {
div("container mx-auto p-4") {
noteListHeader(numberOfDeletedNotes, query)
noteTable(notes)
}
}
fun trash(
loggedInUser: LoggedInUser,
notes: List<PersistedNoteMetadata>,
currentPage: Int,
numberOfPages: Int,
) = renderPage(title = "Notes", loggedInUser = loggedInUser) {
div("container mx-auto p-4") {
div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Deleted notes" }
}
if (notes.isNotEmpty())
deletedNoteTable(notes)
else
span {
if (numberOfPages > 1) +"You went too far"
else +"No deleted notes"
}
if (numberOfPages > 1) pagination(currentPage, numberOfPages, null)
}
}
private fun DIV.pagination(currentPage: Int, numberOfPages: Int, tag: String?) {
val links = mutableListOf<Pair<String, String>>()
// if (currentPage > 1) links += "Previous" to "?page=${currentPage - 1}"
links += (1..numberOfPages).map { page ->
"$page" to (tag?.let { "?page=$page&tag=$it" } ?: "?page=$page")
}
// if (currentPage < numberOfPages) links += "Next" to "?page=${currentPage + 1}"
nav("pages") {
links.forEach { (name, href) ->
a(href, classes = if (name == currentPage.toString()) "active" else null) { +name }
}
}
}
fun renderedNote(loggedInUser: LoggedInUser?, note: PersistedNote, shared: Boolean) = renderPage(
note.meta.title,
loggedInUser = loggedInUser,
scripts = listOf("/highlight.10.1.2.js", "/init-highlight.0.0.1.js")
) {
div("container mx-auto p-4") {
if (shared) {
p("p-4 bg-gray-800") {
+"You are viewing a public note "
}
hr { }
}
div("flex items-center justify-between mb-4") {
h1("text-3xl fond-bold underline") { +note.meta.title }
span("space-x-2") {
note.meta.tags.forEach {
a(href = "/notes?tag=$it", classes = "tag") {
+"#$it"
}
}
}
}
if (!shared) {
noteActionForm(note)
if (note.public) {
p("my-4") {
+"You can share this link : "
a(href = "/notes/public/${note.uuid}", classes = "text-blue-300 underline") {
+"/notes/public/${note.uuid}"
}
}
hr { }
}
}
div {
attributes["id"] = "note"
unsafe {
+note.html
}
}
}
}
private fun DIV.noteActionForm(note: PersistedNote) {
form(method = FormMethod.post, classes = "inline flex space-x-2 justify-end mb-4") {
a(
href = "/notes/${note.uuid}/edit",
classes = "btn btn-green"
) { +"Edit" }
span {
button(
type = ButtonType.submit,
name = if (note.public) "private" else "public",
classes = "font-semibold border-b-4 ${if (note.public) "border-teal-200" else "border-green-500"}" +
" p-2 rounded-l bg-teal-200 text-gray-800"
) {
+"Private"
}
button(
type = ButtonType.submit,
name = if (note.public) "private" else "public",
classes = "font-semibold border-b-4 ${if (!note.public) "border-teal-200" else "border-green-500"}" +
" p-2 rounded-r bg-teal-200 text-gray-800"
) {
+"Public"
}
}
button(
type = ButtonType.submit,
name = "delete",
classes = "btn btn-red"
) { +"Delete" }
}
}
}
@@ -0,0 +1,105 @@
package be.simplenotes.views
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.components.Alert
import be.simplenotes.views.components.alert
import be.simplenotes.views.components.input
import be.simplenotes.views.extensions.summary
import io.konform.validation.ValidationError
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
class SettingView(styles: String) : View(styles) {
fun settings(
loggedInUser: LoggedInUser,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = renderPage("Settings", loggedInUser = loggedInUser) {
div("container mx-auto") {
section("m-4 p-4 bg-gray-800 rounded") {
h1("text-xl") {
+"Welcome "
span("text-teal-200 font-semibold") { +loggedInUser.username }
}
}
section("m-4 p-2 bg-gray-800 rounded flex flex-wrap justify-around items-end") {
form(classes = "m-2", method = FormMethod.post, action = "/export") {
button(name = "display",
classes = "inline btn btn-teal block",
type = submit) { +"Display my data" }
}
form(classes = "m-2", method = FormMethod.post, action = "/export") {
div {
listOf("json", "zip").forEach { format ->
radioInput(name = "format") {
id = format
attributes["value"] = format
if (format == "json") attributes["checked"] = ""
else attributes["class"] = "ml-4"
}
label(classes = "ml-2") {
attributes["for"] = format
+format
}
}
}
button(name = "download", classes = "inline btn btn-green block mt-2", type = submit) {
+"Download my data"
}
}
}
section(classes = "m-4 p-4 bg-gray-800 rounded") {
h2(classes = "mb-4 text-red-400 text-lg font-semibold") {
+"Delete my account"
}
error?.let { alert(Alert.Warning, error) }
details {
if (error != null || validationErrors.isNotEmpty()) {
attributes["open"] = ""
}
summary {
span(classes = "mb-4 font-semibold underline") {
+"Are you sure? "
+"You are about to delete this user, and this process is irreversible !"
}
}
form(classes = "mt-4", method = FormMethod.post) {
input(
id = "password",
placeholder = "Password",
autoComplete = "off",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
)
checkBoxInput(name = "checked") {
id = "checked"
attributes["required"] = ""
label {
attributes["for"] = "checked"
+" Do you want to proceed ?"
}
}
button(
type = submit,
classes = "block mt-4 btn btn-red",
name = "delete"
) { +"I'm sure" }
}
}
}
}
}
}
@@ -0,0 +1,83 @@
package be.simplenotes.views
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.components.Alert
import be.simplenotes.views.components.alert
import be.simplenotes.views.components.input
import be.simplenotes.views.components.submitButton
import io.konform.validation.ValidationError
import kotlinx.html.*
class UserView(styles: String) : View(styles) {
fun register(
loggedInUser: LoggedInUser?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
) = accountForm(
"Register",
"Registration page",
loggedInUser,
error,
validationErrors,
"Create an account",
"Register"
) {
+"Already have an account? "
a(href = "/login", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") { +"Sign In" }
}
fun login(
loggedInUser: LoggedInUser?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
new: Boolean = false,
) = accountForm("Login", "Login page", loggedInUser, error, validationErrors, "Sign In", "Sign In", new) {
+"Don't have an account yet? "
a(href = "/register", classes = "no-underline text-blue-500 hover:text-blue-400 font-bold") {
+"Create an account"
}
}
private fun accountForm(
title: String,
description: String,
loggedInUser: LoggedInUser?,
error: String? = null,
validationErrors: List<ValidationError> = emptyList(),
h1: String,
submit: String,
new: Boolean = false,
footer: FlowContent.() -> Unit,
) = renderPage(title = title, description, loggedInUser = loggedInUser) {
div("centered container mx-auto flex justify-center items-center") {
div("w-full md:w-1/2 lg:w-1/3 m-4") {
div("p-8 mb-6") {
h1("font-semibold text-lg mb-6 text-center") { +h1 }
if (new) alert(Alert.Success, "Your account has been created")
error?.let { alert(Alert.Warning, error) }
form(method = FormMethod.post) {
input(
id = "username",
placeholder = "Username",
autoComplete = "username",
error = validationErrors.find { it.dataPath == ".username" }?.message
)
input(
id = "password",
placeholder = "Password",
autoComplete = "new-password",
type = InputType.password,
error = validationErrors.find { it.dataPath == ".password" }?.message
)
submitButton(submit)
}
}
div("text-center") {
p("text-gray-200 text-sm") {
footer()
}
}
}
}
}
}
@@ -0,0 +1,53 @@
package be.simplenotes.views
import be.simplenotes.types.LoggedInUser
import be.simplenotes.views.components.navbar
import kotlinx.html.*
import kotlinx.html.stream.appendHTML
abstract class View(private val styles: String) {
fun renderPage(
title: String,
description: String? = null,
loggedInUser: LoggedInUser?,
scripts: List<String> = emptyList(),
body: MAIN.() -> Unit = {},
) = buildString {
appendLine("<!DOCTYPE html>")
appendHTML().html {
attributes["lang"] = "en"
head {
meta(charset = "UTF-8")
meta(name = "viewport", content = "width=device-width, initial-scale=1")
title("$title - SimpleNotes")
description?.let { meta(name = "description", content = it) }
link(rel = "preload", href = "/recursive-0.0.1.woff2"){
attributes["as"] = "font"
attributes["type"] = "font/woff2"
attributes["crossorigin"] = "anonymous"
}
link(rel = "stylesheet", href = styles)
icons()
scripts.forEach { src ->
script(src = src) {}
}
}
body("bg-gray-900 text-white") {
navbar(loggedInUser)
main { body() }
}
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun HEAD.icons() {
link(rel = "apple-touch-icon", href = "/apple-touch-icon.png") { attributes["sizes"] = "180x180" }
link(rel = "icon", href = "/favicon-32x32.png", type = "image/png") { attributes["sizes"] = "32x32" }
link(rel = "icon", href = "/favicon-16x16.png", type = "image/png") { attributes["sizes"] = "16x16" }
link(rel = "manifest", href = "/site.webmanifest")
link(rel = "mask-icon", href = "/safari-pinned-tab.svg") { attributes["color"] = "#2c7a7b" }
meta(name = "msapplication-TileColor", content = "#00aba9")
meta(name = "theme-color", content = "#2c7a7b")
}
}
@@ -0,0 +1,12 @@
package be.simplenotes.views
import org.koin.core.qualifier.named
import org.koin.dsl.module
val viewModule = module {
single { ErrorView(get(named("styles"))) }
single { UserView(get(named("styles"))) }
single { BaseView(get(named("styles"))) }
single { SettingView(get(named("styles"))) }
single { NoteView(get(named("styles"))) }
}
@@ -0,0 +1,22 @@
package be.simplenotes.views.components
import kotlinx.html.*
internal fun FlowContent.alert(type: Alert, title: String, details: String? = null, multiline: Boolean = false) {
val colors = when (type) {
Alert.Success -> "bg-green-500 border border-green-400 text-gray-800"
Alert.Warning -> "bg-red-500 border border-red-400 text-red-200"
}
div("$colors px-4 py-3 mb-4 rounded relative") {
attributes["role"] = "alert"
strong("font-bold") { +title }
details?.let {
if (multiline) p { +details }
else span("block sm:inline") { +details }
}
}
}
internal enum class Alert {
Success, Warning
}
@@ -0,0 +1,51 @@
package be.simplenotes.views.components
import be.simplenotes.types.PersistedNoteMetadata
import be.simplenotes.views.utils.toTimeAgo
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
import kotlinx.html.FormMethod.post
import kotlinx.html.ThScope.col
internal fun FlowContent.deletedNoteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
table {
id = "notes"
thead {
tr {
th(col, "w-1/4") { +"Title" }
th(col, "w-1/4") { +"Updated" }
th(col, "w-1/4") { +"Tags" }
th(col, "w-1/4") { +"Restore" }
}
}
tbody {
notes.forEach { (title, tags, updatedAt, uuid) ->
tr {
td { +title }
td("text-center") { +updatedAt.toTimeAgo() }
td { tags(tags) }
td("text-center") {
form(method = post, action = "/notes/deleted/$uuid") {
button(classes = "btn btn-red mb-2", type = submit, name = "delete") {
+"Delete permanently"
}
button(classes = "ml-2 btn btn-green", type = submit, name = "restore") {
+"Restore"
}
}
}
}
}
}
}
}
private fun FlowContent.tags(tags: List<String>) {
ul("inline flex flex-wrap justify-center") {
tags.forEach { tag ->
li("mx-2 my-1") {
span("tag disabled") { +"#$tag" }
}
}
}
}
@@ -0,0 +1,36 @@
package be.simplenotes.views.components
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
internal fun FlowContent.input(
type: InputType = InputType.text,
placeholder: String,
id: String,
autoComplete: String? = null,
error: String? = null
) {
val colors = "bg-gray-800 border-gray-700 focus:border-teal-500 text-white"
div("mb-8") {
input(
type = type,
classes = "$colors rounded w-full border appearance-none focus:outline-none text-base p-2"
) {
attributes["placeholder"] = placeholder
attributes["aria-label"] = placeholder
attributes["name"] = id
attributes["id"] = id
autoComplete?.let { attributes["autocomplete"] = it }
}
error?.let { p("mt-2 text-red-500 text-sm italic") { +"$placeholder $error" } }
}
}
internal fun FlowContent.submitButton(text: String) {
div("flex items-center mt-6") {
button(
type = submit,
classes = "btn btn-teal w-full"
) { +text }
}
}
@@ -0,0 +1,37 @@
package be.simplenotes.views.components
import be.simplenotes.types.LoggedInUser
import kotlinx.html.*
internal fun BODY.navbar(loggedInUser: LoggedInUser?) {
nav {
id = "navbar"
a("/") {
id = "home"
+"SimpleNotes"
}
ul("space-x-2") {
id = "navigation"
if (loggedInUser != null) {
val links = listOf(
"/notes" to "Notes",
"/settings" to "Settings",
)
links.forEach { (href, name) ->
li("txt") {
a(href = href) { +name }
}
}
li {
form(action = "/logout", method = FormMethod.post) {
button(type = ButtonType.submit, classes = "btn btn-green") { +"Logout" }
}
}
} else {
li {
a(href = "/login", classes = "btn btn-green") { +"Sign In" }
}
}
}
}
}
@@ -0,0 +1,37 @@
package be.simplenotes.views.components
import kotlinx.html.*
import kotlinx.html.ButtonType.submit
import kotlinx.html.FormMethod.post
internal fun DIV.noteListHeader(numberOfDeletedNotes: Int, query: String = "") {
div("flex justify-between mb-4") {
h1("text-2xl underline") { +"Notes" }
span {
a(
href = "/notes/trash",
classes = "btn btn-teal"
) { +"Trash ($numberOfDeletedNotes)" }
a(
href = "/notes/new",
classes = "ml-2 btn btn-green"
) { +"New" }
}
}
form(method = post, classes = "md:space-x-2") {
id = "search"
input(name = "search") {
attributes["value"] = query
attributes["aria-label"] = "search"
}
span {
id = "buttons"
button(type = submit, classes = "btn btn-green") {
+"search"
}
a(href = "/notes", classes = "btn btn-red") {
+"clear"
}
}
}
}
@@ -0,0 +1,42 @@
package be.simplenotes.views.components
import be.simplenotes.types.PersistedNoteMetadata
import be.simplenotes.views.utils.toTimeAgo
import kotlinx.html.*
import kotlinx.html.ThScope.col
internal fun FlowContent.noteTable(notes: List<PersistedNoteMetadata>) = div("overflow-x-auto") {
table {
id = "notes"
thead {
tr {
th(col, "w-1/2") { +"Title" }
th(col, "w-1/4") { +"Updated" }
th(col, "w-1/4") { +"Tags" }
}
}
tbody {
notes.forEach { (title, tags, updatedAt, uuid) ->
tr {
td {
a(classes = "text-blue-200 font-semibold underline", href = "/notes/$uuid") { +title }
}
td("text-center") {
+updatedAt.toTimeAgo()
}
td { tags(tags) }
}
}
}
}
}
private fun FlowContent.tags(tags: List<String>) {
ul("inline flex flex-wrap justify-center") {
tags.forEach { tag ->
li("mx-2 my-1") {
a(href = "?tag=$tag", classes = "tag") { +"#$tag" }
}
}
}
}
@@ -0,0 +1,15 @@
package be.simplenotes.views.extensions
import kotlinx.html.*
internal class SUMMARY(consumer: TagConsumer<*>) :
HTMLTag(
"summary", consumer, emptyMap(),
inlineTag = true,
emptyTag = false
),
HtmlInlineTag
internal fun DETAILS.summary(block: SUMMARY.() -> Unit = {}) {
SUMMARY(consumer).visit(block)
}
@@ -0,0 +1,10 @@
package be.simplenotes.views.utils
import org.ocpsoft.prettytime.PrettyTime
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*
private val prettyTime = PrettyTime()
internal fun LocalDateTime.toTimeAgo(): String = prettyTime.format(Date.from(atZone(ZoneId.systemDefault()).toInstant()))