Compare commits
2 Commits
main
...
5851cb6559
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5851cb6559 | ||
|
|
a012c69693 |
@@ -20,7 +20,6 @@ RUN sbt "set test in assembly := false" assembly
|
|||||||
FROM eclipse-temurin:25-jre-alpine
|
FROM eclipse-temurin:25-jre-alpine
|
||||||
|
|
||||||
RUN addgroup -S app && adduser -S app -G app
|
RUN addgroup -S app && adduser -S app -G app
|
||||||
RUN mkdir -p /data && chown app:app /data
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=build --chown=app:app /app/target/scala-3.7.1/demoapp.jar ./demoapp.jar
|
COPY --from=build --chown=app:app /app/target/scala-3.7.1/demoapp.jar ./demoapp.jar
|
||||||
|
|||||||
@@ -1,10 +1,162 @@
|
|||||||
import com.greenfossil.thorium.{*, given}
|
import com.sun.net.httpserver.{HttpExchange, HttpServer}
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
object Main:
|
object Main:
|
||||||
|
|
||||||
def main(args: Array[String]): Unit =
|
def main(args: Array[String]): Unit =
|
||||||
val port = sys.env.getOrElse("PORT", "8080").toInt
|
val port = sys.env.getOrElse("PORT", "8080").toInt
|
||||||
Server(port)
|
val server = HttpServer.create(InetSocketAddress(port), 0)
|
||||||
.addServices(TaskController)
|
|
||||||
.start()
|
server.createContext("/health", exchange => {
|
||||||
|
val response = """{"status":"ok"}"""
|
||||||
|
exchange.getResponseHeaders.set("Content-Type", "application/json")
|
||||||
|
exchange.sendResponseHeaders(200, response.getBytes.length)
|
||||||
|
val os = exchange.getResponseBody
|
||||||
|
os.write(response.getBytes)
|
||||||
|
os.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
server.createContext("/tasks", exchange => {
|
||||||
|
val method = exchange.getRequestMethod
|
||||||
|
val path = exchange.getRequestURI.getPath
|
||||||
|
|
||||||
|
(method, path) match
|
||||||
|
case ("POST", "/tasks") =>
|
||||||
|
val body = String(exchange.getRequestBody.readAllBytes(), StandardCharsets.UTF_8)
|
||||||
|
val title = parseFormField(body, "title")
|
||||||
|
TaskController.createTask(title)
|
||||||
|
redirect(exchange, "/")
|
||||||
|
|
||||||
|
case ("POST", p) if p.endsWith("/toggle") =>
|
||||||
|
val id = p.stripPrefix("/tasks/").stripSuffix("/toggle")
|
||||||
|
TaskController.toggleTask(id)
|
||||||
|
redirect(exchange, "/")
|
||||||
|
|
||||||
|
case ("POST", p) if p.endsWith("/delete") =>
|
||||||
|
val id = p.stripPrefix("/tasks/").stripSuffix("/delete")
|
||||||
|
TaskController.deleteTask(id)
|
||||||
|
redirect(exchange, "/")
|
||||||
|
|
||||||
|
case _ =>
|
||||||
|
exchange.sendResponseHeaders(404, -1)
|
||||||
|
exchange.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
server.createContext("/", exchange => {
|
||||||
|
val path = exchange.getRequestURI.getPath
|
||||||
|
if path == "/" || path.isEmpty then
|
||||||
|
val html = renderPage()
|
||||||
|
exchange.getResponseHeaders.set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
exchange.sendResponseHeaders(200, html.getBytes(StandardCharsets.UTF_8).length)
|
||||||
|
val os = exchange.getResponseBody
|
||||||
|
os.write(html.getBytes(StandardCharsets.UTF_8))
|
||||||
|
os.close()
|
||||||
|
else
|
||||||
|
exchange.sendResponseHeaders(404, -1)
|
||||||
|
exchange.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
server.setExecutor(null)
|
||||||
|
server.start()
|
||||||
println(s"demoapp started on port $port")
|
println(s"demoapp started on port $port")
|
||||||
println(s" app: ${SiteConfig.appName} v${SiteConfig.appVersion}")
|
println(s" app: ${SiteConfig.appName} v${SiteConfig.appVersion}")
|
||||||
|
|
||||||
|
private def redirect(exchange: HttpExchange, location: String): Unit =
|
||||||
|
exchange.getResponseHeaders.set("Location", location)
|
||||||
|
exchange.sendResponseHeaders(303, -1)
|
||||||
|
exchange.close()
|
||||||
|
|
||||||
|
private def parseFormField(body: String, field: String): String =
|
||||||
|
body.split("&").map(_.split("=", 2)).collectFirst {
|
||||||
|
case Array(k, v) if k == field =>
|
||||||
|
URLDecoder.decode(v, StandardCharsets.UTF_8)
|
||||||
|
}.getOrElse("")
|
||||||
|
|
||||||
|
private def esc(s: String): String =
|
||||||
|
s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
.replace("\"", """).replace("'", "'")
|
||||||
|
|
||||||
|
private def renderPage(): String =
|
||||||
|
val cfg = SiteConfig
|
||||||
|
val tasks = TaskStore.all()
|
||||||
|
val totalCount = tasks.size
|
||||||
|
val doneCount = tasks.count(_.completed)
|
||||||
|
|
||||||
|
val taskRows = tasks.map { t =>
|
||||||
|
val checkedClass = if t.completed then "line-through text-gray-400" else ""
|
||||||
|
val checkIcon = if t.completed then "✓" else ""
|
||||||
|
s"""
|
||||||
|
<div class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 group transition-colors">
|
||||||
|
<form method="POST" action="/tasks/${t.id}/toggle" class="flex-none">
|
||||||
|
<button type="submit"
|
||||||
|
class="w-6 h-6 rounded-full border-2 ${if t.completed then "bg-emerald-500 border-emerald-500 text-white" else "border-gray-300 hover:border-teal-500"} flex items-center justify-center text-xs transition-colors">
|
||||||
|
$checkIcon
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<span class="flex-1 $checkedClass">${esc(t.title)}</span>
|
||||||
|
<form method="POST" action="/tasks/${t.id}/delete" class="flex-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button type="submit" class="text-red-400 hover:text-red-600 text-sm px-2 py-1 rounded hover:bg-red-50 transition-colors">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>"""
|
||||||
|
}.mkString("\n")
|
||||||
|
|
||||||
|
s"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${esc(cfg.appName)}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: '${cfg.font}', sans-serif; font-size: ${cfg.fontSize}; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 min-h-screen">
|
||||||
|
<header style="background-color: ${cfg.bgColor};" class="shadow-lg">
|
||||||
|
<div class="max-w-2xl mx-auto px-6 py-5 flex items-center justify-between">
|
||||||
|
<h1 class="text-white text-2xl font-bold" style="font-family: 'Instrument Serif', serif;">
|
||||||
|
${esc(cfg.appName)}
|
||||||
|
</h1>
|
||||||
|
<span class="text-white/70 text-sm font-mono">v${esc(cfg.appVersion)}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-2xl mx-auto px-6 py-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800">Tasks</h2>
|
||||||
|
<span class="text-sm text-gray-500">$doneCount / $totalCount completed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
$taskRows
|
||||||
|
${if tasks.isEmpty then """<p class="text-gray-400 text-center py-8">No tasks yet. Add one below!</p>""" else ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">Add Task</h2>
|
||||||
|
<form method="POST" action="/tasks" class="flex gap-3">
|
||||||
|
<input type="text" name="title" placeholder="What needs to be done?"
|
||||||
|
required
|
||||||
|
class="flex-1 border-2 border-gray-200 rounded-lg px-4 py-2 focus:border-teal-600 focus:ring-2 focus:ring-teal-600/10 outline-none transition-colors"
|
||||||
|
style="border-color: #E2E8F0;">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 rounded-lg text-white font-medium hover:-translate-y-0.5 hover:shadow-lg transition-all duration-300"
|
||||||
|
style="background-color: ${cfg.bgColor};">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="text-center text-gray-400 text-xs mt-8">
|
||||||
|
Powered by <span class="font-semibold">Clouderized</span> · Thorium/Scala
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|||||||
@@ -1,128 +1,14 @@
|
|||||||
import com.greenfossil.thorium.{*, given}
|
|
||||||
import com.greenfossil.data.mapping.Mapping
|
|
||||||
import com.greenfossil.data.mapping.Mapping.*
|
|
||||||
import com.linecorp.armeria.common.MediaType
|
|
||||||
import com.linecorp.armeria.server.annotation.{Get, Post, Param}
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
object TaskController:
|
object TaskController:
|
||||||
|
|
||||||
private val titleField = Mapping("title", text)
|
def createTask(title: String): Unit =
|
||||||
|
|
||||||
@Get("/health")
|
|
||||||
def health = Action: _ =>
|
|
||||||
Ok("""{"status":"ok"}""").as(MediaType.JSON)
|
|
||||||
|
|
||||||
@Get("/")
|
|
||||||
def index = Action: _ =>
|
|
||||||
Ok(renderPage()).as(MediaType.HTML_UTF_8)
|
|
||||||
|
|
||||||
@Post("/tasks")
|
|
||||||
def create = Action: request =>
|
|
||||||
val bound = titleField.bindFromRequest()(using request)
|
|
||||||
if bound.hasErrors then Redirect("/")
|
|
||||||
else
|
|
||||||
val title = bound.typedValueOpt.get
|
|
||||||
if title.trim.nonEmpty then TaskStore.add(title)
|
if title.trim.nonEmpty then TaskStore.add(title)
|
||||||
Redirect("/")
|
|
||||||
|
|
||||||
@Post("/tasks/:id/toggle")
|
def toggleTask(id: String): Unit =
|
||||||
def toggle(@Param id: String) = Action: _ =>
|
|
||||||
try TaskStore.toggle(UUID.fromString(id))
|
try TaskStore.toggle(UUID.fromString(id))
|
||||||
catch case _: IllegalArgumentException => ()
|
catch case _: IllegalArgumentException => ()
|
||||||
Redirect("/")
|
|
||||||
|
|
||||||
@Post("/tasks/:id/delete")
|
def deleteTask(id: String): Unit =
|
||||||
def delete(@Param id: String) = Action: _ =>
|
|
||||||
try TaskStore.delete(UUID.fromString(id))
|
try TaskStore.delete(UUID.fromString(id))
|
||||||
catch case _: IllegalArgumentException => ()
|
catch case _: IllegalArgumentException => ()
|
||||||
Redirect("/")
|
|
||||||
|
|
||||||
// ── rendering helpers (moved from Main) ──────────────────────────────────
|
|
||||||
|
|
||||||
private def esc(s: String): String =
|
|
||||||
s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
||||||
.replace("\"", """).replace("'", "'")
|
|
||||||
|
|
||||||
private def renderPage(): String =
|
|
||||||
val cfg = SiteConfig
|
|
||||||
val tasks = TaskStore.all()
|
|
||||||
val totalCount = tasks.size
|
|
||||||
val doneCount = tasks.count(_.completed)
|
|
||||||
|
|
||||||
val taskRows = tasks.map { t =>
|
|
||||||
val checkedClass = if t.completed then "line-through text-gray-400" else ""
|
|
||||||
val checkIcon = if t.completed then "✓" else ""
|
|
||||||
s"""
|
|
||||||
<div class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 group transition-colors">
|
|
||||||
<form method="POST" action="/tasks/${t.id}/toggle" class="flex-none">
|
|
||||||
<button type="submit"
|
|
||||||
class="w-6 h-6 rounded-full border-2 ${if t.completed then "bg-emerald-500 border-emerald-500 text-white" else "border-gray-300 hover:border-teal-500"} flex items-center justify-center text-xs transition-colors">
|
|
||||||
$checkIcon
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<span class="flex-1 $checkedClass">${esc(t.title)}</span>
|
|
||||||
<form method="POST" action="/tasks/${t.id}/delete" class="flex-none opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button type="submit" class="text-red-400 hover:text-red-600 text-sm px-2 py-1 rounded hover:bg-red-50 transition-colors">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>"""
|
|
||||||
}.mkString("\n")
|
|
||||||
|
|
||||||
s"""<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${esc(cfg.appName)}</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { font-family: '${cfg.font}', sans-serif; font-size: ${cfg.fontSize}; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50 min-h-screen">
|
|
||||||
<header style="background-color: ${cfg.bgColor};" class="shadow-lg">
|
|
||||||
<div class="max-w-2xl mx-auto px-6 py-5 flex items-center justify-between">
|
|
||||||
<h1 class="text-white text-2xl font-bold" style="font-family: 'Instrument Serif', serif;">
|
|
||||||
${esc(cfg.appName)}
|
|
||||||
</h1>
|
|
||||||
<span class="text-white/70 text-sm font-mono">v${esc(cfg.appVersion)}</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="max-w-2xl mx-auto px-6 py-8">
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-800">Tasks</h2>
|
|
||||||
<span class="text-sm text-gray-500">$doneCount / $totalCount completed</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
$taskRows
|
|
||||||
${if tasks.isEmpty then """<p class="text-gray-400 text-center py-8">No tasks yet. Add one below!</p>""" else ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-md p-6">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">Add Task</h2>
|
|
||||||
<form method="POST" action="/tasks" class="flex gap-3">
|
|
||||||
<input type="text" name="title" placeholder="What needs to be done?"
|
|
||||||
required
|
|
||||||
class="flex-1 border-2 border-gray-200 rounded-lg px-4 py-2 focus:border-teal-600 focus:ring-2 focus:ring-teal-600/10 outline-none transition-colors"
|
|
||||||
style="border-color: #E2E8F0;">
|
|
||||||
<button type="submit"
|
|
||||||
class="px-6 py-2 rounded-lg text-white font-medium hover:-translate-y-0.5 hover:shadow-lg transition-all duration-300"
|
|
||||||
style="background-color: ${cfg.bgColor};">
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="text-center text-gray-400 text-xs mt-8">
|
|
||||||
Powered by <span class="font-semibold">Clouderized</span> · Thorium/Scala
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|||||||
Reference in New Issue
Block a user