Compare commits
7 Commits
252ed3b4ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905a9112f8 | ||
|
|
6692d5a053 | ||
|
|
9f0ecf67da | ||
|
|
776a6e8973 | ||
|
|
4760fd22c0 | ||
|
|
371f2c949d | ||
|
|
78a5f98f11 |
@@ -20,6 +20,7 @@ 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
|
||||||
|
|||||||
121
README.md
121
README.md
@@ -1,97 +1,83 @@
|
|||||||
# demoapp — Clouderized Demo App
|
# demoapp — Clouderized Demo App
|
||||||
|
|
||||||
A task manager built with Scala 3 + Thorium, used to demo the full Clouderized deployment cycle: edit config, push, rebuild, see changes live.
|
A task manager built with Scala 3 + Thorium, used to demonstrate Clouderized's core value prop: **stateful app upgrades with zero data loss**.
|
||||||
|
|
||||||
|
Tasks are persisted in SQLite backed by a Docker named volume (`demouser-data`). Rebuilding or upgrading the container leaves all user data intact.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Access to `git.clouderized.com` (Gitea)
|
- Access to `git.clouderized.com` (Gitea)
|
||||||
- The app deployed at `demoapp.clouderized.com`
|
- The app deployed at `demouser.clouderized.com`
|
||||||
|
- VPS SSH access: `ssh -i ssh/id_rsa -p 9876 cldrzd@194.233.75.123`
|
||||||
|
|
||||||
## Running the Demo
|
---
|
||||||
|
|
||||||
### 1. Show the live app
|
## v1 → v2 Upgrade Demo (with data persistence)
|
||||||
|
|
||||||
Open `https://demoapp.clouderized.com` in a browser. Point out:
|
This is the primary demo sequence. It shows that Clouderized can upgrade a running app without wiping user data.
|
||||||
|
|
||||||
- The **teal header** with app name and version (v1.0.0)
|
### 1. Deploy v1.0.0
|
||||||
- CRUD functionality — add, complete, and delete tasks
|
|
||||||
- The `/health` endpoint returning `{"status":"ok"}`
|
|
||||||
|
|
||||||
### 2. Make a visible change
|
On the VPS:
|
||||||
|
|
||||||
Edit `src/main/resources/site.conf`:
|
|
||||||
|
|
||||||
```diff
|
|
||||||
app {
|
|
||||||
- name = "DemoCust Tasks"
|
|
||||||
- version = "1.0.0"
|
|
||||||
+ name = "Clouderized Tasks"
|
|
||||||
+ version = "1.1.0"
|
|
||||||
theme {
|
|
||||||
- background = "#0F766E"
|
|
||||||
+ background = "#1E40AF"
|
|
||||||
font = "DM Sans"
|
|
||||||
- fontSize = "16px"
|
|
||||||
+ fontSize = "18px"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This changes the header from **teal to blue**, bumps the version, and increases font size.
|
|
||||||
|
|
||||||
### 3. Push the change
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/main/resources/site.conf
|
cd ~/customers/demouser/demoapp && git fetch --tags && git checkout v1.0.0
|
||||||
git commit -m "Update theme to blue, bump to v1.1.0"
|
cd ~/customers/demouser && docker compose up -d --build
|
||||||
git push
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Watch the rebuild
|
Visit `https://demouser.clouderized.com`:
|
||||||
|
- **Teal header**, version badge shows **v1.0.0**
|
||||||
|
- Three pre-seeded tasks are present
|
||||||
|
- `/health` returns `{"status":"ok"}`
|
||||||
|
|
||||||
Go to Gitea Actions at `git.clouderized.com/cldrzd/demoapp/actions` and watch the pipeline:
|
### 2. Add user data
|
||||||
|
|
||||||
1. `sbt assembly` builds the fat JAR
|
In the browser, add 2–3 tasks and delete one of the pre-seeded tasks. This simulates real user activity before an upgrade.
|
||||||
2. Docker builds the container image
|
|
||||||
3. The old container is replaced with the new one
|
|
||||||
|
|
||||||
### 5. See the result
|
### 3. Upgrade to v2.0.0
|
||||||
|
|
||||||
Refresh the browser. The page now shows:
|
On the VPS:
|
||||||
|
|
||||||
- **Blue header** instead of teal
|
|
||||||
- Version **v1.1.0** in the top-right
|
|
||||||
- Larger font size
|
|
||||||
|
|
||||||
### 6. Reset for the next demo
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git revert HEAD --no-edit
|
cd ~/customers/demouser/demoapp && git checkout v2.0.0
|
||||||
git push
|
cd ~/customers/demouser && docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The app rebuilds and returns to its original teal theme at v1.0.0.
|
Revisit `https://demouser.clouderized.com`:
|
||||||
|
- **Blue header**, version badge shows **v2.0.0**
|
||||||
|
- All tasks added in step 2 are still there
|
||||||
|
- Pre-seeded tasks are not re-added (seeding is skipped when the table is non-empty)
|
||||||
|
|
||||||
|
### 4. Verify persistence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the VPS — confirm the volume exists and is mounted
|
||||||
|
docker volume inspect demouser-data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Persistence Works
|
||||||
|
|
||||||
|
| Component | Detail |
|
||||||
|
|-----------|--------|
|
||||||
|
| Storage | SQLite at `/data/tasks.db` inside the container |
|
||||||
|
| Volume | Docker named volume `demouser-data` mounted at `/data` |
|
||||||
|
| Env var | `DB_PATH=/data/tasks.db` (set in `docker-compose.yml`) |
|
||||||
|
| Seeding | Pre-seed runs only when the `tasks` table is empty |
|
||||||
|
|
||||||
|
The named volume `demouser-data` survives `docker compose up --build` — data is never lost on rebuild.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Config Reference
|
## Config Reference
|
||||||
|
|
||||||
All visual changes are driven by `src/main/resources/site.conf`:
|
All visual changes are driven by `src/main/resources/site.conf`:
|
||||||
|
|
||||||
| Key | Default | What it controls |
|
| Key | v1.0.0 | v2.0.0 | What it controls |
|
||||||
|-----|---------|-----------------|
|
|-----|--------|--------|-----------------|
|
||||||
| `app.name` | `"DemoCust Tasks"` | Header title |
|
| `app.version` | `"1.0.0"` | `"2.0.0"` | Version badge in header |
|
||||||
| `app.version` | `"1.0.0"` | Version badge in header |
|
| `app.theme.background` | `"#0F766E"` (teal) | `"#1D4ED8"` (blue) | Header and button color |
|
||||||
| `app.theme.background` | `"#0F766E"` (teal) | Header and button color |
|
|
||||||
| `app.theme.font` | `"DM Sans"` | Body font family |
|
|
||||||
| `app.theme.fontSize` | `"16px"` | Base font size |
|
|
||||||
|
|
||||||
## Suggested color swaps for demos
|
|
||||||
|
|
||||||
| Color | Hex | Effect |
|
|
||||||
|-------|-----|--------|
|
|
||||||
| Teal (default) | `#0F766E` | Clouderized brand color |
|
|
||||||
| Blue | `#1E40AF` | Obvious visual change |
|
|
||||||
| Purple | `#7C3AED` | Another clear contrast |
|
|
||||||
| Red | `#DC2626` | High-contrast demo |
|
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
@@ -105,6 +91,5 @@ All visual changes are driven by `src/main/resources/site.conf`:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Task storage is **in-memory** — it resets on every redeploy, which keeps the demo clean.
|
|
||||||
- The app runs on the **Pro tier** (2GB RAM, 2 vCPU) since JVM needs the headroom.
|
- The app runs on the **Pro tier** (2GB RAM, 2 vCPU) since JVM needs the headroom.
|
||||||
- Three sample tasks are pre-seeded on startup.
|
- Local dev uses `./tasks.db` (relative path) when `DB_PATH` is not set.
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ lazy val root = project
|
|||||||
libraryDependencies ++= Seq(
|
libraryDependencies ++= Seq(
|
||||||
"com.greenfossil" %% "thorium" % thoriumVersion,
|
"com.greenfossil" %% "thorium" % thoriumVersion,
|
||||||
"com.typesafe" % "config" % "1.4.3",
|
"com.typesafe" % "config" % "1.4.3",
|
||||||
"ch.qos.logback" % "logback-classic" % "1.5.6"
|
"ch.qos.logback" % "logback-classic" % "1.5.6",
|
||||||
|
"org.xerial" % "sqlite-jdbc" % "3.47.1.0"
|
||||||
),
|
),
|
||||||
|
|
||||||
assembly / mainClass := Some("Main"),
|
assembly / mainClass := Some("Main"),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
app {
|
app {
|
||||||
name = "DemoCust Tasks"
|
name = "DemoCust Tasks"
|
||||||
version = "1.0.0"
|
version = "2.0.0"
|
||||||
theme {
|
theme {
|
||||||
background = "#0F766E"
|
background = "#1D4ED8"
|
||||||
font = "DM Sans"
|
font = "DM Sans"
|
||||||
fontSize = "16px"
|
fontSize = "16px"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,162 +1,10 @@
|
|||||||
import com.sun.net.httpserver.{HttpExchange, HttpServer}
|
import com.greenfossil.thorium.{*, given}
|
||||||
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
|
||||||
val server = HttpServer.create(InetSocketAddress(port), 0)
|
Server(port)
|
||||||
|
.addServices(TaskController)
|
||||||
server.createContext("/health", exchange => {
|
.start()
|
||||||
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,14 +1,128 @@
|
|||||||
|
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:
|
||||||
|
|
||||||
def createTask(title: String): Unit =
|
private val titleField = Mapping("title", text)
|
||||||
if title.trim.nonEmpty then TaskStore.add(title)
|
|
||||||
|
|
||||||
def toggleTask(id: 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)
|
||||||
|
Redirect("/")
|
||||||
|
|
||||||
|
@Post("/tasks/:id/toggle")
|
||||||
|
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("/")
|
||||||
|
|
||||||
def deleteTask(id: String): Unit =
|
@Post("/tasks/:id/delete")
|
||||||
|
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>"""
|
||||||
|
|||||||
@@ -1,41 +1,87 @@
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.sql.{Connection, DriverManager}
|
||||||
import scala.jdk.CollectionConverters.*
|
|
||||||
|
|
||||||
case class Task(id: UUID, title: String, completed: Boolean)
|
case class Task(id: UUID, title: String, completed: Boolean)
|
||||||
|
|
||||||
object TaskStore:
|
object TaskStore:
|
||||||
private val tasks = ConcurrentHashMap[UUID, Task]()
|
private val dbPath = sys.env.getOrElse("DB_PATH", "./tasks.db")
|
||||||
|
private val url = s"jdbc:sqlite:$dbPath"
|
||||||
|
|
||||||
// Pre-seed sample tasks
|
Class.forName("org.sqlite.JDBC")
|
||||||
|
|
||||||
|
private def connect(): Connection = DriverManager.getConnection(url)
|
||||||
|
|
||||||
|
// Initialize schema and pre-seed if empty
|
||||||
locally {
|
locally {
|
||||||
|
val conn = connect()
|
||||||
|
try
|
||||||
|
val stmt = conn.createStatement()
|
||||||
|
stmt.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0)"
|
||||||
|
)
|
||||||
|
val rs = stmt.executeQuery("SELECT COUNT(*) FROM tasks")
|
||||||
|
val count = rs.getInt(1)
|
||||||
|
if count == 0 then
|
||||||
val samples = List(
|
val samples = List(
|
||||||
"Deploy Scala app to Clouderized",
|
"Deploy Scala app to Clouderized",
|
||||||
"Set up CI/CD with Gitea Actions",
|
"Set up CI/CD with Gitea Actions",
|
||||||
"Configure custom domain"
|
"Configure custom domain"
|
||||||
)
|
)
|
||||||
|
val ps = conn.prepareStatement("INSERT INTO tasks (id, title, completed) VALUES (?, ?, 0)")
|
||||||
samples.foreach { title =>
|
samples.foreach { title =>
|
||||||
val id = UUID.randomUUID()
|
ps.setString(1, UUID.randomUUID().toString)
|
||||||
tasks.put(id, Task(id, title, false))
|
ps.setString(2, title)
|
||||||
|
ps.executeUpdate()
|
||||||
}
|
}
|
||||||
|
finally conn.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
def all(): List[Task] =
|
def all(): List[Task] =
|
||||||
tasks.values().asScala.toList.sortBy(_.title)
|
val conn = connect()
|
||||||
|
try
|
||||||
|
val rs = conn.createStatement().executeQuery("SELECT id, title, completed FROM tasks ORDER BY title")
|
||||||
|
val buf = collection.mutable.ListBuffer.empty[Task]
|
||||||
|
while rs.next() do
|
||||||
|
buf += Task(UUID.fromString(rs.getString("id")), rs.getString("title"), rs.getInt("completed") == 1)
|
||||||
|
buf.toList
|
||||||
|
finally conn.close()
|
||||||
|
|
||||||
def add(title: String): Task =
|
def add(title: String): Task =
|
||||||
val id = UUID.randomUUID()
|
val id = UUID.randomUUID()
|
||||||
val task = Task(id, title.trim, false)
|
val conn = connect()
|
||||||
tasks.put(id, task)
|
try
|
||||||
task
|
val ps = conn.prepareStatement("INSERT INTO tasks (id, title, completed) VALUES (?, ?, 0)")
|
||||||
|
ps.setString(1, id.toString)
|
||||||
|
ps.setString(2, title.trim)
|
||||||
|
ps.executeUpdate()
|
||||||
|
Task(id, title.trim, false)
|
||||||
|
finally conn.close()
|
||||||
|
|
||||||
def toggle(id: UUID): Unit =
|
def toggle(id: UUID): Unit =
|
||||||
Option(tasks.get(id)).foreach { task =>
|
val conn = connect()
|
||||||
tasks.put(id, task.copy(completed = !task.completed))
|
try
|
||||||
}
|
val ps = conn.prepareStatement(
|
||||||
|
"UPDATE tasks SET completed = CASE WHEN completed = 1 THEN 0 ELSE 1 END WHERE id = ?"
|
||||||
|
)
|
||||||
|
ps.setString(1, id.toString)
|
||||||
|
ps.executeUpdate()
|
||||||
|
finally conn.close()
|
||||||
|
|
||||||
def delete(id: UUID): Unit =
|
def delete(id: UUID): Unit =
|
||||||
tasks.remove(id)
|
val conn = connect()
|
||||||
|
try
|
||||||
|
val ps = conn.prepareStatement("DELETE FROM tasks WHERE id = ?")
|
||||||
|
ps.setString(1, id.toString)
|
||||||
|
ps.executeUpdate()
|
||||||
|
finally conn.close()
|
||||||
|
|
||||||
def get(id: UUID): Option[Task] =
|
def get(id: UUID): Option[Task] =
|
||||||
Option(tasks.get(id))
|
val conn = connect()
|
||||||
|
try
|
||||||
|
val ps = conn.prepareStatement("SELECT id, title, completed FROM tasks WHERE id = ?")
|
||||||
|
ps.setString(1, id.toString)
|
||||||
|
val rs = ps.executeQuery()
|
||||||
|
if rs.next() then
|
||||||
|
Some(Task(UUID.fromString(rs.getString("id")), rs.getString("title"), rs.getInt("completed") == 1))
|
||||||
|
else None
|
||||||
|
finally conn.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user