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 at.favre.lib.crypto.bcrypt.BCrypt
|
||||||
import ch.qos.logback.classic.Level
|
import ch.qos.logback.classic.Level
|
||||||
|
import com.github.mustachejava.DefaultMustacheFactory
|
||||||
import com.wbrawner.twigs.*
|
import com.wbrawner.twigs.*
|
||||||
import com.wbrawner.twigs.db.*
|
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.model.Session
|
||||||
import com.wbrawner.twigs.service.budget.BudgetService
|
import com.wbrawner.twigs.service.budget.BudgetService
|
||||||
import com.wbrawner.twigs.service.budget.DefaultBudgetService
|
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.DefaultUserService
|
||||||
import com.wbrawner.twigs.service.user.UserService
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
import com.wbrawner.twigs.storage.PasswordHasher
|
import com.wbrawner.twigs.storage.PasswordHasher
|
||||||
|
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
||||||
import com.wbrawner.twigs.web.webRoutes
|
import com.wbrawner.twigs.web.webRoutes
|
||||||
import com.zaxxer.hikari.HikariConfig
|
import com.zaxxer.hikari.HikariConfig
|
||||||
import com.zaxxer.hikari.HikariDataSource
|
import com.zaxxer.hikari.HikariDataSource
|
||||||
|
import io.ktor.client.request.forms.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.*
|
import io.ktor.server.auth.*
|
||||||
import io.ktor.server.cio.*
|
import io.ktor.server.cio.*
|
||||||
import io.ktor.server.engine.*
|
import io.ktor.server.engine.*
|
||||||
|
import io.ktor.server.mustache.*
|
||||||
import io.ktor.server.plugins.callloging.*
|
import io.ktor.server.plugins.callloging.*
|
||||||
import io.ktor.server.plugins.contentnegotiation.*
|
import io.ktor.server.plugins.contentnegotiation.*
|
||||||
import io.ktor.server.plugins.cors.routing.*
|
import io.ktor.server.plugins.cors.routing.*
|
||||||
|
@ -124,7 +130,7 @@ fun Application.module() {
|
||||||
application.environment.log.info("Found session!")
|
application.environment.log.info("Found session!")
|
||||||
}
|
}
|
||||||
return@validate if (twoWeeksFromNow.isAfter(storedSession.expiration)) {
|
return@validate if (twoWeeksFromNow.isAfter(storedSession.expiration)) {
|
||||||
sessionRepository.save(storedSession.copy(expiration = twoWeeksFromNow))
|
sessionRepository.save(storedSession.updateExpiration(newExpiration = twoWeeksFromNow))
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
@ -176,14 +182,27 @@ fun Application.moduleWithDependencies(
|
||||||
install(Sessions) {
|
install(Sessions) {
|
||||||
header<Session>("Authorization") {
|
header<Session>("Authorization") {
|
||||||
serializer = object : SessionSerializer<Session> {
|
serializer = object : SessionSerializer<Session> {
|
||||||
override fun deserialize(text: String): Session {
|
override fun deserialize(text: String): HeaderSession {
|
||||||
this@moduleWithDependencies.environment.log.info("Deserializing session!")
|
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
|
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) {
|
install(ContentNegotiation) {
|
||||||
json(json = Json {
|
json(json = Json {
|
||||||
|
@ -196,6 +215,8 @@ fun Application.moduleWithDependencies(
|
||||||
prettyPrint = false
|
prettyPrint = false
|
||||||
useArrayPolymorphism = true
|
useArrayPolymorphism = true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
formData()
|
||||||
}
|
}
|
||||||
install(CORS) {
|
install(CORS) {
|
||||||
allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable
|
allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable
|
||||||
|
@ -225,12 +246,15 @@ fun Application.moduleWithDependencies(
|
||||||
allowHeader("DNT")
|
allowHeader("DNT")
|
||||||
allowCredentials = true
|
allowCredentials = true
|
||||||
}
|
}
|
||||||
|
install(Mustache) {
|
||||||
|
mustacheFactory = DefaultMustacheFactory("templates")
|
||||||
|
}
|
||||||
budgetRoutes(budgetService)
|
budgetRoutes(budgetService)
|
||||||
categoryRoutes(categoryService)
|
categoryRoutes(categoryService)
|
||||||
recurringTransactionRoutes(recurringTransactionService)
|
recurringTransactionRoutes(recurringTransactionService)
|
||||||
transactionRoutes(transactionService)
|
transactionRoutes(transactionService)
|
||||||
userRoutes(userService)
|
userRoutes(userService)
|
||||||
webRoutes()
|
webRoutes(budgetService, userService)
|
||||||
launch {
|
launch {
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
jobs.forEach { it.run() }
|
jobs.forEach { it.run() }
|
||||||
|
|
|
@ -6,9 +6,20 @@ import com.wbrawner.twigs.twoWeeksFromNow
|
||||||
import io.ktor.server.auth.*
|
import io.ktor.server.auth.*
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
data class Session(
|
open class Session(
|
||||||
override val id: String = randomString(),
|
override val id: String = randomString(),
|
||||||
val userId: String = "",
|
val userId: String = "",
|
||||||
val token: String = randomString(255),
|
open val token: String = randomString(255),
|
||||||
var expiration: Instant = twoWeeksFromNow
|
val expiration: Instant = twoWeeksFromNow
|
||||||
) : Principal, Identifiable
|
) : 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(),
|
override val id: String = randomString(),
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
val email: String = ""
|
val email: String? = null
|
||||||
) : Principal, Identifiable
|
) : Principal, Identifiable
|
||||||
|
|
||||||
enum class Permission {
|
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-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-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||||
ktor-server-cors = { module = "io.ktor:ktor-server-cors", 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-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" }
|
||||||
ktor-server-test = { module = "io.ktor:ktor-server-test-host", 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" }
|
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||||
|
|
|
@ -22,6 +22,8 @@ interface UserService {
|
||||||
|
|
||||||
suspend fun user(userId: String): UserResponse
|
suspend fun user(userId: String): UserResponse
|
||||||
|
|
||||||
|
suspend fun session(token: String): SessionResponse
|
||||||
|
|
||||||
suspend fun save(request: UserRequest, targetUserId: String, requestingUserId: String): UserResponse
|
suspend fun save(request: UserRequest, targetUserId: String, requestingUserId: String): UserResponse
|
||||||
|
|
||||||
suspend fun delete(targetUserId: String, requestingUserId: String)
|
suspend fun delete(targetUserId: String, requestingUserId: String)
|
||||||
|
@ -68,7 +70,7 @@ class DefaultUserService(
|
||||||
User(
|
User(
|
||||||
name = request.username,
|
name = request.username,
|
||||||
password = passwordHasher.hash(request.password),
|
password = passwordHasher.hash(request.password),
|
||||||
email = if (request.email.isNullOrBlank()) "" else request.email
|
email = if (request.email.isNullOrBlank()) null else request.email
|
||||||
)
|
)
|
||||||
).asResponse()
|
).asResponse()
|
||||||
}
|
}
|
||||||
|
@ -77,7 +79,7 @@ class DefaultUserService(
|
||||||
userRepository.findAll(nameOrEmail = request.username)
|
userRepository.findAll(nameOrEmail = request.username)
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?.let {
|
?.let {
|
||||||
val email = it.email
|
val email = it.email ?: return@let
|
||||||
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
|
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
|
||||||
emailService.sendPasswordResetEmail(passwordResetToken, email)
|
emailService.sendPasswordResetEmail(passwordResetToken, email)
|
||||||
}
|
}
|
||||||
|
@ -132,6 +134,13 @@ class DefaultUserService(
|
||||||
?: throw HttpException(HttpStatusCode.NotFound)
|
?: 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(
|
override suspend fun save(
|
||||||
request: UserRequest,
|
request: UserRequest,
|
||||||
targetUserId: String,
|
targetUserId: String,
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
`java-library`
|
`java-library`
|
||||||
alias(libs.plugins.kotlin.jvm)
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
@ -7,8 +5,10 @@ plugins {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(kotlin("stdlib"))
|
implementation(kotlin("stdlib"))
|
||||||
|
implementation(project(":core"))
|
||||||
|
implementation(project(":service"))
|
||||||
api(libs.ktor.server.core)
|
api(libs.ktor.server.core)
|
||||||
implementation(libs.ktor.server.html)
|
api(libs.ktor.server.mustache)
|
||||||
testImplementation(libs.junit.jupiter.api)
|
testImplementation(libs.junit.jupiter.api)
|
||||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||||
}
|
}
|
||||||
|
@ -16,37 +16,3 @@ dependencies {
|
||||||
tasks.getByName<Test>("test") {
|
tasks.getByName<Test>("test") {
|
||||||
useJUnitPlatform()
|
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
|
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.application.*
|
||||||
import io.ktor.server.http.content.*
|
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.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
import io.ktor.server.sessions.*
|
||||||
|
|
||||||
fun Application.webRoutes() {
|
fun Application.webRoutes(
|
||||||
|
budgetService: BudgetService,
|
||||||
|
userService: UserService
|
||||||
|
) {
|
||||||
routing {
|
routing {
|
||||||
staticResources("/", "web")
|
staticResources("/", "static")
|
||||||
intercept(ApplicationCallPipeline.Setup) {
|
get("/") {
|
||||||
if (!call.request.path().startsWith("/api") && !call.request.path().matches(Regex(".*\\.\\w+$"))) {
|
call.sessions.get(CookieSession::class)
|
||||||
call.resolveResource("web/index.html")?.let {
|
?.let { userService.session(it.token) }
|
||||||
call.respond(it)
|
?.let { session ->
|
||||||
return@intercept finish()
|
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 |
|
@ -5,7 +5,6 @@
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-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="http://www.w3.org/2000/svg"
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
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");
|
--logo: url("/img/logo-color.svg");
|
||||||
--background-color-primary: #ffffff;
|
--background-color-primary: #ffffff;
|
||||||
--background-color-secondary: #bbbbbb;
|
--background-color-secondary: #bbbbbb;
|
||||||
|
--border-radius: 5px;
|
||||||
|
--input-padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media all and (prefers-color-scheme: dark) {
|
@media all and (prefers-color-scheme: dark) {
|
||||||
|
@ -35,12 +37,13 @@ body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.button {
|
||||||
border: 1px solid var(--color-accent);
|
border: 1px solid var(--color-accent);
|
||||||
border-radius: 1rem;
|
border-radius: var(--border-radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 1rem;
|
padding: var(--input-padding);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-full-width {
|
.flex-full-width {
|
||||||
|
@ -74,6 +77,25 @@ button {
|
||||||
width: 200px;
|
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) {
|
@media all and (max-width: 400px) {
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
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')
|
|
||||||
}
|
|