Initial commit: Scala/Thorium task manager demo app

This commit is contained in:
davidtio
2026-02-28 12:56:14 +08:00
commit 93e8401ed3
12 changed files with 430 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>

View File

@@ -0,0 +1,9 @@
app {
name = "DemoCust Tasks"
version = "1.0.0"
theme {
background = "#0F766E"
font = "DM Sans"
fontSize = "16px"
}
}

162
src/main/scala/Main.scala Normal file
View File

@@ -0,0 +1,162 @@
import com.sun.net.httpserver.{HttpExchange, HttpServer}
import java.net.InetSocketAddress
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
object Main:
def main(args: Array[String]): Unit =
val port = sys.env.getOrElse("PORT", "8080").toInt
val server = HttpServer.create(InetSocketAddress(port), 0)
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" 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;")
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 "&#10003;" 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> &middot; Thorium/Scala
</footer>
</main>
</body>
</html>"""

View File

@@ -0,0 +1,10 @@
import com.typesafe.config.ConfigFactory
object SiteConfig:
private val config = ConfigFactory.load("site")
val appName: String = config.getString("app.name")
val appVersion: String = config.getString("app.version")
val bgColor: String = config.getString("app.theme.background")
val font: String = config.getString("app.theme.font")
val fontSize: String = config.getString("app.theme.fontSize")

View File

@@ -0,0 +1,14 @@
import java.util.UUID
object TaskController:
def createTask(title: String): Unit =
if title.trim.nonEmpty then TaskStore.add(title)
def toggleTask(id: String): Unit =
try TaskStore.toggle(UUID.fromString(id))
catch case _: IllegalArgumentException => ()
def deleteTask(id: String): Unit =
try TaskStore.delete(UUID.fromString(id))
catch case _: IllegalArgumentException => ()

View File

@@ -0,0 +1,41 @@
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import scala.jdk.CollectionConverters.*
case class Task(id: UUID, title: String, completed: Boolean)
object TaskStore:
private val tasks = ConcurrentHashMap[UUID, Task]()
// Pre-seed sample tasks
locally {
val samples = List(
"Deploy Scala app to Clouderized",
"Set up CI/CD with Gitea Actions",
"Configure custom domain"
)
samples.foreach { title =>
val id = UUID.randomUUID()
tasks.put(id, Task(id, title, false))
}
}
def all(): List[Task] =
tasks.values().asScala.toList.sortBy(_.title)
def add(title: String): Task =
val id = UUID.randomUUID()
val task = Task(id, title.trim, false)
tasks.put(id, task)
task
def toggle(id: UUID): Unit =
Option(tasks.get(id)).foreach { task =>
tasks.put(id, task.copy(completed = !task.completed))
}
def delete(id: UUID): Unit =
tasks.remove(id)
def get(id: UUID): Option[Task] =
Option(tasks.get(id))