Add mustache support with static assets and functional login/registration
|
@ -2,8 +2,11 @@ package com.wbrawner.twigs.server
|
|||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
import ch.qos.logback.classic.Level
|
||||
import com.github.mustachejava.DefaultMustacheFactory
|
||||
import com.wbrawner.twigs.*
|
||||
import com.wbrawner.twigs.db.*
|
||||
import com.wbrawner.twigs.model.CookieSession
|
||||
import com.wbrawner.twigs.model.HeaderSession
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.service.budget.BudgetService
|
||||
import com.wbrawner.twigs.service.budget.DefaultBudgetService
|
||||
|
@ -16,15 +19,18 @@ import com.wbrawner.twigs.service.transaction.TransactionService
|
|||
import com.wbrawner.twigs.service.user.DefaultUserService
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import com.wbrawner.twigs.storage.PasswordHasher
|
||||
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
||||
import com.wbrawner.twigs.web.webRoutes
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.mustache.*
|
||||
import io.ktor.server.plugins.callloging.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
|
@ -124,7 +130,7 @@ fun Application.module() {
|
|||
application.environment.log.info("Found session!")
|
||||
}
|
||||
return@validate if (twoWeeksFromNow.isAfter(storedSession.expiration)) {
|
||||
sessionRepository.save(storedSession.copy(expiration = twoWeeksFromNow))
|
||||
sessionRepository.save(storedSession.updateExpiration(newExpiration = twoWeeksFromNow))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
@ -176,14 +182,27 @@ fun Application.moduleWithDependencies(
|
|||
install(Sessions) {
|
||||
header<Session>("Authorization") {
|
||||
serializer = object : SessionSerializer<Session> {
|
||||
override fun deserialize(text: String): Session {
|
||||
override fun deserialize(text: String): HeaderSession {
|
||||
this@moduleWithDependencies.environment.log.info("Deserializing session!")
|
||||
return Session(token = text.substringAfter("Bearer "))
|
||||
return HeaderSession(token = text.substringAfter("Bearer "))
|
||||
}
|
||||
|
||||
override fun serialize(session: Session): String = session.token
|
||||
}
|
||||
}
|
||||
|
||||
cookie<CookieSession>(TWIGS_SESSION_COOKIE) {
|
||||
serializer = object : SessionSerializer<CookieSession> {
|
||||
override fun deserialize(text: String): CookieSession {
|
||||
this@moduleWithDependencies.environment.log.info("Deserializing session!")
|
||||
return CookieSession(token = text)
|
||||
}
|
||||
|
||||
override fun serialize(session: CookieSession): String = session.token
|
||||
}
|
||||
cookie.httpOnly = true
|
||||
cookie.secure = true
|
||||
}
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(json = Json {
|
||||
|
@ -196,6 +215,8 @@ fun Application.moduleWithDependencies(
|
|||
prettyPrint = false
|
||||
useArrayPolymorphism = true
|
||||
})
|
||||
|
||||
formData()
|
||||
}
|
||||
install(CORS) {
|
||||
allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable
|
||||
|
@ -225,12 +246,15 @@ fun Application.moduleWithDependencies(
|
|||
allowHeader("DNT")
|
||||
allowCredentials = true
|
||||
}
|
||||
install(Mustache) {
|
||||
mustacheFactory = DefaultMustacheFactory("templates")
|
||||
}
|
||||
budgetRoutes(budgetService)
|
||||
categoryRoutes(categoryService)
|
||||
recurringTransactionRoutes(recurringTransactionService)
|
||||
transactionRoutes(transactionService)
|
||||
userRoutes(userService)
|
||||
webRoutes()
|
||||
webRoutes(budgetService, userService)
|
||||
launch {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
jobs.forEach { it.run() }
|
||||
|
|
|
@ -6,9 +6,20 @@ import com.wbrawner.twigs.twoWeeksFromNow
|
|||
import io.ktor.server.auth.*
|
||||
import java.time.Instant
|
||||
|
||||
data class Session(
|
||||
open class Session(
|
||||
override val id: String = randomString(),
|
||||
val userId: String = "",
|
||||
val token: String = randomString(255),
|
||||
var expiration: Instant = twoWeeksFromNow
|
||||
) : Principal, Identifiable
|
||||
open val token: String = randomString(255),
|
||||
val expiration: Instant = twoWeeksFromNow
|
||||
) : Principal, Identifiable {
|
||||
fun updateExpiration(newExpiration: Instant) = Session(
|
||||
id = id,
|
||||
userId = userId,
|
||||
token = token,
|
||||
expiration = newExpiration
|
||||
)
|
||||
}
|
||||
|
||||
data class HeaderSession(override val token: String) : Session(token = token)
|
||||
|
||||
data class CookieSession(override val token: String) : Session(token = token)
|
|
@ -8,7 +8,7 @@ data class User(
|
|||
override val id: String = randomString(),
|
||||
val name: String = "",
|
||||
val password: String = "",
|
||||
val email: String = ""
|
||||
val email: String? = null
|
||||
) : Principal, Identifiable
|
||||
|
||||
enum class Permission {
|
||||
|
|
|
@ -29,7 +29,7 @@ ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
|
|||
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
|
||||
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
|
||||
ktor-server-html = { module = "io.ktor:ktor-server-html-builder", version.ref = "ktor" }
|
||||
ktor-server-mustache = { module = "io.ktor:ktor-server-mustache", version.ref = "ktor" }
|
||||
ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" }
|
||||
ktor-server-test = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
|
||||
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||
|
|
|
@ -22,6 +22,8 @@ interface UserService {
|
|||
|
||||
suspend fun user(userId: String): UserResponse
|
||||
|
||||
suspend fun session(token: String): SessionResponse
|
||||
|
||||
suspend fun save(request: UserRequest, targetUserId: String, requestingUserId: String): UserResponse
|
||||
|
||||
suspend fun delete(targetUserId: String, requestingUserId: String)
|
||||
|
@ -68,7 +70,7 @@ class DefaultUserService(
|
|||
User(
|
||||
name = request.username,
|
||||
password = passwordHasher.hash(request.password),
|
||||
email = if (request.email.isNullOrBlank()) "" else request.email
|
||||
email = if (request.email.isNullOrBlank()) null else request.email
|
||||
)
|
||||
).asResponse()
|
||||
}
|
||||
|
@ -77,7 +79,7 @@ class DefaultUserService(
|
|||
userRepository.findAll(nameOrEmail = request.username)
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
val email = it.email
|
||||
val email = it.email ?: return@let
|
||||
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
|
||||
emailService.sendPasswordResetEmail(passwordResetToken, email)
|
||||
}
|
||||
|
@ -132,6 +134,13 @@ class DefaultUserService(
|
|||
?: throw HttpException(HttpStatusCode.NotFound)
|
||||
}
|
||||
|
||||
override suspend fun session(token: String): SessionResponse {
|
||||
return sessionRepository.findAll(token = token)
|
||||
.firstOrNull()
|
||||
?.asResponse()
|
||||
?: throw HttpException(HttpStatusCode.Unauthorized)
|
||||
}
|
||||
|
||||
override suspend fun save(
|
||||
request: UserRequest,
|
||||
targetUserId: String,
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import java.util.*
|
||||
|
||||
plugins {
|
||||
`java-library`
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
|
@ -7,46 +5,14 @@ plugins {
|
|||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":service"))
|
||||
api(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.html)
|
||||
api(libs.ktor.server.mustache)
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// TODO: Replace this hack with either a git submodule or an internal Kotlin-based UI
|
||||
tasks.register("package") {
|
||||
doLast {
|
||||
val built = File(rootProject.rootDir.parent, "twigs-web/dist/twigs")
|
||||
if (built.exists()) {
|
||||
built.deleteRecursively()
|
||||
}
|
||||
val dest = File(project.projectDir, "src/main/resources/twigs")
|
||||
if (dest.exists()) {
|
||||
dest.deleteRecursively()
|
||||
}
|
||||
var command = listOf(
|
||||
"cd", "../../twigs-web", ";",
|
||||
"npm", "i", ";",
|
||||
"npm", "run", "package"
|
||||
)
|
||||
command = if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) {
|
||||
listOf("powershell", "-Command") + command
|
||||
} else {
|
||||
listOf("bash", "-c", "\"${command.joinToString(" ")}\"")
|
||||
}
|
||||
exec {
|
||||
commandLine(command)
|
||||
}
|
||||
if (!built.copyRecursively(dest, true) || !dest.isDirectory) {
|
||||
throw GradleException("Failed to copy files from ${built.absolutePath} to ${dest.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//tasks.getByName("processResources") {
|
||||
// dependsOn.add("package")
|
||||
//}
|
||||
}
|
3
web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt
Normal file
|
@ -0,0 +1,3 @@
|
|||
package com.wbrawner.twigs.web
|
||||
|
||||
open class Page(val title: String)
|
|
@ -1,21 +1,34 @@
|
|||
package com.wbrawner.twigs.web
|
||||
|
||||
import com.wbrawner.twigs.model.CookieSession
|
||||
import com.wbrawner.twigs.service.budget.BudgetService
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import com.wbrawner.twigs.web.user.userWebRoutes
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.http.content.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.mustache.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.sessions.*
|
||||
|
||||
fun Application.webRoutes() {
|
||||
fun Application.webRoutes(
|
||||
budgetService: BudgetService,
|
||||
userService: UserService
|
||||
) {
|
||||
routing {
|
||||
staticResources("/", "web")
|
||||
intercept(ApplicationCallPipeline.Setup) {
|
||||
if (!call.request.path().startsWith("/api") && !call.request.path().matches(Regex(".*\\.\\w+$"))) {
|
||||
call.resolveResource("web/index.html")?.let {
|
||||
call.respond(it)
|
||||
return@intercept finish()
|
||||
}
|
||||
}
|
||||
staticResources("/", "static")
|
||||
get("/") {
|
||||
call.sessions.get(CookieSession::class)
|
||||
?.let { userService.session(it.token) }
|
||||
?.let { session ->
|
||||
application.environment.log.info("Session found!")
|
||||
budgetService.budgetsForUser(session.userId)
|
||||
.firstOrNull()
|
||||
?.let { budget ->
|
||||
call.respondRedirect("/budgets/${budget.id}")
|
||||
} ?: call.respondRedirect("/budgets")
|
||||
} ?: call.respond(MustacheContent("index.mustache", null))
|
||||
}
|
||||
}
|
||||
userWebRoutes(userService)
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package com.wbrawner.twigs.web.user
|
||||
|
||||
import com.wbrawner.twigs.web.Page
|
||||
|
||||
data class LoginPage(val username: String = "", val error: String? = null) : Page("Login")
|
||||
|
||||
data class RegisterPage(val username: String = "", val email: String = "", val error: String? = null) : Page("Register")
|
|
@ -0,0 +1,85 @@
|
|||
package com.wbrawner.twigs.web.user
|
||||
|
||||
import com.wbrawner.twigs.model.CookieSession
|
||||
import com.wbrawner.twigs.service.HttpException
|
||||
import com.wbrawner.twigs.service.user.LoginRequest
|
||||
import com.wbrawner.twigs.service.user.UserRequest
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.mustache.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.sessions.*
|
||||
import io.ktor.server.util.*
|
||||
|
||||
const val TWIGS_SESSION_COOKIE = "twigsSession"
|
||||
|
||||
fun Application.userWebRoutes(userService: UserService) {
|
||||
routing {
|
||||
route("/login") {
|
||||
get {
|
||||
call.respond(MustacheContent("login.mustache", LoginPage()))
|
||||
}
|
||||
|
||||
post {
|
||||
val request = call.receiveParameters().toLoginRequest()
|
||||
try {
|
||||
val session = userService.login(request)
|
||||
call.sessions.set(CookieSession(session.token))
|
||||
call.respondRedirect("/")
|
||||
} catch (e: HttpException) {
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent("login.mustache", LoginPage(username = request.username, error = e.message))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
route("/register") {
|
||||
get {
|
||||
call.respond(MustacheContent("register.mustache", RegisterPage()))
|
||||
}
|
||||
|
||||
post {
|
||||
val request = call.receiveParameters()
|
||||
val userRequest = request.toUserRequest()
|
||||
val confirmPassword = request.getOrFail("confirmPassword")
|
||||
if (userRequest.password != confirmPassword) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
MustacheContent(
|
||||
"register.mustache",
|
||||
userRequest.toPage("passwords don't match")
|
||||
)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
try {
|
||||
userService.register(userRequest)
|
||||
val session = userService.login(
|
||||
LoginRequest(
|
||||
requireNotNull(userRequest.username),
|
||||
requireNotNull(userRequest.password)
|
||||
)
|
||||
)
|
||||
call.sessions.set(CookieSession(session.token))
|
||||
call.respondRedirect("/")
|
||||
} catch (e: HttpException) {
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent("register.mustache", userRequest.toPage(error = e.message))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Parameters.toLoginRequest() = LoginRequest(getOrFail("username"), getOrFail("password"))
|
||||
|
||||
private fun Parameters.toUserRequest() = UserRequest(getOrFail("username"), getOrFail("password"), get("email"))
|
||||
|
||||
private fun UserRequest.toPage(error: String? = null) =
|
||||
RegisterPage(username = username.orEmpty(), email = email.orEmpty(), error = error)
|
BIN
web/src/main/resources/static/favicon.ico
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
web/src/main/resources/static/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 969 B |
BIN
web/src/main/resources/static/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
web/src/main/resources/static/icons/favicon-96x96.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
web/src/main/resources/static/icons/icon-128x128.png
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
web/src/main/resources/static/icons/icon-144x144.png
Normal file
After Width: | Height: | Size: 7.3 KiB |
BIN
web/src/main/resources/static/icons/icon-152x152.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
web/src/main/resources/static/icons/icon-192x192.png
Normal file
After Width: | Height: | Size: 9.6 KiB |
BIN
web/src/main/resources/static/icons/icon-384x384.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
web/src/main/resources/static/icons/icon-512x512.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
web/src/main/resources/static/icons/icon-72x72.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
BIN
web/src/main/resources/static/icons/icon-96x96.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-128x128.png
Normal file
After Width: | Height: | Size: 4 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-144x144.png
Normal file
After Width: | Height: | Size: 4.5 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-152x152.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-192x192.png
Normal file
After Width: | Height: | Size: 5.8 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-384x384.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-512x512.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-72x72.png
Normal file
After Width: | Height: | Size: 2 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-96x96.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
web/src/main/resources/static/icons/touch-icon.png
Normal file
After Width: | Height: | Size: 675 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
@ -2,20 +2,19 @@
|
|||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 270.93333 270.93334"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.4 (unknown)"
|
||||
sodipodi:docname="White.svg">
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 270.93333 270.93334"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.4 (unknown)"
|
||||
sodipodi:docname="White.svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
99
web/src/main/resources/static/manifest.json
Normal file
|
@ -0,0 +1,99 @@
|
|||
{
|
||||
"name": "Twigs",
|
||||
"short_name": "Twigs",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-maskable-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-maskable-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-maskable-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-maskable-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-maskable-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-maskable-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -11,6 +11,8 @@ html, body {
|
|||
--logo: url("/img/logo-color.svg");
|
||||
--background-color-primary: #ffffff;
|
||||
--background-color-secondary: #bbbbbb;
|
||||
--border-radius: 5px;
|
||||
--input-padding: 10px;
|
||||
}
|
||||
|
||||
@media all and (prefers-color-scheme: dark) {
|
||||
|
@ -35,12 +37,13 @@ body {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
.button {
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
padding: 1rem;
|
||||
padding: var(--input-padding);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.flex-full-width {
|
||||
|
@ -74,6 +77,25 @@ button {
|
|||
width: 200px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
form > input {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--input-padding);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@media all and (max-width: 400px) {
|
||||
button {
|
||||
width: 100%;
|
15
web/src/main/resources/templates/index.mustache
Normal file
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
{{> partials/head }}
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
<div class="flex-full-width">
|
||||
<a href="/login" class="button button-primary">Login</a>
|
||||
<a href="/register" class="button button-secondary">Register</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
23
web/src/main/resources/templates/login.mustache
Normal file
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
{{> partials/head }}
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
{{#error }}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/error}}
|
||||
<form action="/login" method="post">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" name="username" value="{{ username }}"/>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password"/>
|
||||
<input type="submit" class="button button-primary" value="Login"/>
|
||||
</form>
|
||||
<a href="/resetpassword" style="padding: var(--input-padding)">Forgot your password?</a>
|
||||
<a href="/register" style="padding: var(--input-padding)">Create an account</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
13
web/src/main/resources/templates/partials/head.mustache
Normal file
|
@ -0,0 +1,13 @@
|
|||
<link rel="stylesheet" href="/style.css"/>
|
||||
<title>{{ title }}</title>
|
||||
<meta charset="utf-8">
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/icons/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png">
|
||||
<meta name="apple-mobile-web-app-title" content="Twigs">
|
||||
<meta name="application-name" content="Twigs">
|
||||
<meta name="theme-color" content="#FFFFFF">
|
||||
<link rel="manifest" href="manifest.json">
|
26
web/src/main/resources/templates/register.mustache
Normal file
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
{{> partials/head }}
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
{{#error }}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/error}}
|
||||
<form action="/register" method="post">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" name="username" value="{{ username }}"/>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" name="email" value="{{ email }}"/>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password"/>
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input id="confirm-password" type="password" name="confirmPassword"/>
|
||||
<input type="submit" class="button button-primary" value="Login"/>
|
||||
</form>
|
||||
<a href="/login" style="padding: var(--input-padding)">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,18 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Twigs</title>
|
||||
<link rel="stylesheet" href="style.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
<noscript>
|
||||
<p>JavaScript is required to use this app</p>
|
||||
</noscript>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript" src="js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,16 +0,0 @@
|
|||
window.onload = () => {
|
||||
const container = document.getElementsByClassName('center')[0]
|
||||
container.innerHTML += `
|
||||
<div class="flex-full-width">
|
||||
<button class="button-primary" onclick="login()">Login</button>
|
||||
<button class="button-secondary" onclick="register()">Register</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function login() {
|
||||
console.log('show login form')
|
||||
}
|
||||
|
||||
function register() {
|
||||
console.log('show registration form')
|
||||
}
|