|
|
@@ -0,0 +1,779 @@
|
|
|
+using Astralis;
|
|
|
+using Invercargill;
|
|
|
+using Invercargill.DataStructures;
|
|
|
+using Inversion;
|
|
|
+
|
|
|
+/**
|
|
|
+ * HTMX Example
|
|
|
+ *
|
|
|
+ * Demonstrates using htmx (from CDN) with Astralis for dynamic content updates
|
|
|
+ * without full page reloads. Shows various htmx attributes and swap strategies:
|
|
|
+ * - hx-get: Make GET requests to swap content
|
|
|
+ * - hx-post: Make POST requests for actions
|
|
|
+ * - hx-swap: Control how content is inserted (innerHTML, outerHTML, beforeend, etc.)
|
|
|
+ * - hx-trigger: Control when requests are triggered (click, load, every, etc.)
|
|
|
+ * - hx-target: Specify where to swap the response
|
|
|
+ * - hx-indicator: Show loading states
|
|
|
+ *
|
|
|
+ * htmx CDN: https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js
|
|
|
+ *
|
|
|
+ * Usage: htmx-example [port]
|
|
|
+ */
|
|
|
+
|
|
|
+// Application state for the click counter
|
|
|
+class ClickState : Object {
|
|
|
+ public int click_count { get; set; }
|
|
|
+ public DateTime last_click { get; set; }
|
|
|
+ public string click_history { get; set; }
|
|
|
+
|
|
|
+ public ClickState() {
|
|
|
+ click_count = 0;
|
|
|
+ last_click = new DateTime.now_local();
|
|
|
+ click_history = "";
|
|
|
+ }
|
|
|
+
|
|
|
+ public void record_click(string button_name) {
|
|
|
+ click_count++;
|
|
|
+ last_click = new DateTime.now_local();
|
|
|
+ var timestamp = last_click.format("%H:%M:%S");
|
|
|
+ click_history = @"[$timestamp] $button_name\n" + click_history;
|
|
|
+ // Keep only last 10 entries
|
|
|
+ var lines = click_history.split("\n");
|
|
|
+ if (lines.length > 10) {
|
|
|
+ click_history = string.joinv("\n", lines[0:10]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Task list item
|
|
|
+class TaskItem : Object {
|
|
|
+ public int id { get; set; }
|
|
|
+ public string text { get; set; }
|
|
|
+ public bool completed { get; set; }
|
|
|
+
|
|
|
+ public TaskItem(int id, string text) {
|
|
|
+ this.id = id;
|
|
|
+ this.text = text;
|
|
|
+ this.completed = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Task manager state
|
|
|
+class TaskManager : Object {
|
|
|
+ private int next_id = 1;
|
|
|
+ public List<TaskItem> tasks;
|
|
|
+
|
|
|
+ public TaskManager() {
|
|
|
+ tasks = new List<TaskItem>();
|
|
|
+ // Add some initial tasks
|
|
|
+ add_task("Learn htmx basics");
|
|
|
+ add_task("Build dynamic web apps");
|
|
|
+ add_task("Enjoy simple hypermedia");
|
|
|
+ }
|
|
|
+
|
|
|
+ public TaskItem add_task(string text) {
|
|
|
+ var task = new TaskItem(next_id++, text);
|
|
|
+ tasks.append(task);
|
|
|
+ return task;
|
|
|
+ }
|
|
|
+
|
|
|
+ public bool toggle_task(int id) {
|
|
|
+ foreach (var task in tasks) {
|
|
|
+ if (task.id == id) {
|
|
|
+ task.completed = !task.completed;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ public bool delete_task(int id) {
|
|
|
+ unowned List<TaskItem>? found_link = null;
|
|
|
+ unowned List<TaskItem>? link = tasks.first();
|
|
|
+ while (link != null) {
|
|
|
+ if (link.data.id == id) {
|
|
|
+ found_link = link;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ link = link.next;
|
|
|
+ }
|
|
|
+ if (found_link != null) {
|
|
|
+ tasks.delete_link(found_link);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ public TaskItem? get_task(int id) {
|
|
|
+ foreach (var task in tasks) {
|
|
|
+ if (task.id == id) {
|
|
|
+ return task;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * CSS content for the htmx example page.
|
|
|
+ */
|
|
|
+private const string HTMX_CSS = """
|
|
|
+body {
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
+ max-width: 900px;
|
|
|
+ margin: 0 auto;
|
|
|
+ padding: 20px;
|
|
|
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
|
+ min-height: 100vh;
|
|
|
+ color: #eee;
|
|
|
+}
|
|
|
+.card {
|
|
|
+ background: rgba(255, 255, 255, 0.1);
|
|
|
+ backdrop-filter: blur(10px);
|
|
|
+ border-radius: 12px;
|
|
|
+ padding: 25px;
|
|
|
+ margin: 15px 0;
|
|
|
+ border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
+}
|
|
|
+h1, h2, h3 {
|
|
|
+ color: #fff;
|
|
|
+ margin-top: 0;
|
|
|
+}
|
|
|
+.htmx-logo {
|
|
|
+ font-size: 48px;
|
|
|
+ text-align: center;
|
|
|
+ margin-bottom: 10px;
|
|
|
+}
|
|
|
+.subtitle {
|
|
|
+ text-align: center;
|
|
|
+ color: #aaa;
|
|
|
+ margin-bottom: 30px;
|
|
|
+}
|
|
|
+.button-group {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ justify-content: center;
|
|
|
+ margin: 20px 0;
|
|
|
+}
|
|
|
+button, .btn {
|
|
|
+ padding: 12px 24px;
|
|
|
+ border: none;
|
|
|
+ border-radius: 6px;
|
|
|
+ cursor: pointer;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ transition: all 0.2s;
|
|
|
+ text-decoration: none;
|
|
|
+ display: inline-block;
|
|
|
+}
|
|
|
+button:hover, .btn:hover {
|
|
|
+ transform: translateY(-2px);
|
|
|
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
|
+}
|
|
|
+.btn-primary {
|
|
|
+ background: #3b82f6;
|
|
|
+ color: white;
|
|
|
+}
|
|
|
+.btn-primary:hover {
|
|
|
+ background: #2563eb;
|
|
|
+}
|
|
|
+.btn-success {
|
|
|
+ background: #10b981;
|
|
|
+ color: white;
|
|
|
+}
|
|
|
+.btn-success:hover {
|
|
|
+ background: #059669;
|
|
|
+}
|
|
|
+.btn-danger {
|
|
|
+ background: #ef4444;
|
|
|
+ color: white;
|
|
|
+}
|
|
|
+.btn-danger:hover {
|
|
|
+ background: #dc2626;
|
|
|
+}
|
|
|
+.btn-warning {
|
|
|
+ background: #f59e0b;
|
|
|
+ color: white;
|
|
|
+}
|
|
|
+.btn-warning:hover {
|
|
|
+ background: #d97706;
|
|
|
+}
|
|
|
+.btn-small {
|
|
|
+ padding: 6px 12px;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+.counter-display {
|
|
|
+ text-align: center;
|
|
|
+ padding: 30px;
|
|
|
+ background: rgba(0, 0, 0, 0.2);
|
|
|
+ border-radius: 8px;
|
|
|
+ margin: 20px 0;
|
|
|
+}
|
|
|
+.counter-value {
|
|
|
+ font-size: 72px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #3b82f6;
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+.info-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
|
+ gap: 15px;
|
|
|
+ margin: 20px 0;
|
|
|
+}
|
|
|
+.info-item {
|
|
|
+ background: rgba(0, 0, 0, 0.2);
|
|
|
+ padding: 15px;
|
|
|
+ border-radius: 6px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+.info-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #aaa;
|
|
|
+ text-transform: uppercase;
|
|
|
+ letter-spacing: 1px;
|
|
|
+}
|
|
|
+.info-value {
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #fff;
|
|
|
+ margin-top: 5px;
|
|
|
+}
|
|
|
+.task-list {
|
|
|
+ list-style: none;
|
|
|
+ padding: 0;
|
|
|
+ margin: 0;
|
|
|
+}
|
|
|
+.task-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 12px;
|
|
|
+ background: rgba(0, 0, 0, 0.2);
|
|
|
+ border-radius: 6px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ transition: all 0.2s;
|
|
|
+}
|
|
|
+.task-item:hover {
|
|
|
+ background: rgba(0, 0, 0, 0.3);
|
|
|
+}
|
|
|
+.task-item.completed .task-text {
|
|
|
+ text-decoration: line-through;
|
|
|
+ color: #666;
|
|
|
+}
|
|
|
+.task-text {
|
|
|
+ flex: 1;
|
|
|
+}
|
|
|
+.task-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 5px;
|
|
|
+}
|
|
|
+.add-task-form {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+.add-task-form input[type="text"] {
|
|
|
+ flex: 1;
|
|
|
+ padding: 12px;
|
|
|
+ border: 1px solid rgba(255, 255, 255, 0.2);
|
|
|
+ border-radius: 6px;
|
|
|
+ background: rgba(0, 0, 0, 0.2);
|
|
|
+ color: #fff;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+.add-task-form input[type="text"]::placeholder {
|
|
|
+ color: #666;
|
|
|
+}
|
|
|
+.add-task-form input[type="text"]:focus {
|
|
|
+ outline: none;
|
|
|
+ border-color: #3b82f6;
|
|
|
+}
|
|
|
+.history-log {
|
|
|
+ background: rgba(0, 0, 0, 0.3);
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 15px;
|
|
|
+ font-family: monospace;
|
|
|
+ font-size: 13px;
|
|
|
+ max-height: 200px;
|
|
|
+ overflow-y: auto;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ color: #10b981;
|
|
|
+}
|
|
|
+.loading-indicator {
|
|
|
+ display: none;
|
|
|
+ text-align: center;
|
|
|
+ padding: 10px;
|
|
|
+}
|
|
|
+.htmx-request .loading-indicator {
|
|
|
+ display: block;
|
|
|
+}
|
|
|
+.htmx-request .loading-indicator + .content {
|
|
|
+ opacity: 0.5;
|
|
|
+}
|
|
|
+.spinner {
|
|
|
+ border: 3px solid rgba(255, 255, 255, 0.1);
|
|
|
+ border-top: 3px solid #3b82f6;
|
|
|
+ border-radius: 50%;
|
|
|
+ width: 24px;
|
|
|
+ height: 24px;
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
+ display: inline-block;
|
|
|
+}
|
|
|
+@keyframes spin {
|
|
|
+ 0% { transform: rotate(0deg); }
|
|
|
+ 100% { transform: rotate(360deg); }
|
|
|
+}
|
|
|
+code {
|
|
|
+ background: rgba(0, 0, 0, 0.3);
|
|
|
+ padding: 2px 6px;
|
|
|
+ border-radius: 4px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #f59e0b;
|
|
|
+}
|
|
|
+pre {
|
|
|
+ background: rgba(0, 0, 0, 0.3);
|
|
|
+ color: #aed581;
|
|
|
+ padding: 15px;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow-x: auto;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+.feature-list {
|
|
|
+ margin: 0;
|
|
|
+ padding-left: 20px;
|
|
|
+}
|
|
|
+.feature-list li {
|
|
|
+ margin: 8px 0;
|
|
|
+ color: #ccc;
|
|
|
+}
|
|
|
+.live-clock {
|
|
|
+ font-size: 24px;
|
|
|
+ font-family: monospace;
|
|
|
+ text-align: center;
|
|
|
+ padding: 15px;
|
|
|
+ background: rgba(0, 0, 0, 0.2);
|
|
|
+ border-radius: 6px;
|
|
|
+}
|
|
|
+.clicker-area {
|
|
|
+ text-align: center;
|
|
|
+ padding: 30px;
|
|
|
+ background: rgba(0, 0, 0, 0.1);
|
|
|
+ border-radius: 8px;
|
|
|
+ margin: 20px 0;
|
|
|
+ border: 2px dashed rgba(255, 255, 255, 0.2);
|
|
|
+}
|
|
|
+.clicker-area:hover {
|
|
|
+ border-color: #3b82f6;
|
|
|
+ background: rgba(59, 130, 246, 0.1);
|
|
|
+}
|
|
|
+""";
|
|
|
+
|
|
|
+/**
|
|
|
+ * HtmxTemplate - Main page template with htmx CDN script.
|
|
|
+ */
|
|
|
+class HtmxTemplate : MarkupTemplate {
|
|
|
+ protected override string get_markup() {
|
|
|
+ return """<!DOCTYPE html>
|
|
|
+<html lang="en">
|
|
|
+<head>
|
|
|
+ <meta charset="UTF-8"/>
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
|
+ <title>htmx Example - Astralis</title>
|
|
|
+ <link rel="stylesheet" href="/styles.css"/>
|
|
|
+ <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="htmx-logo">⚡</div>
|
|
|
+ <p class="subtitle">htmx + Astralis: High Power Tools for HTML</p>
|
|
|
+
|
|
|
+ <div class="card" id="main-card">
|
|
|
+ <h1>htmx Examples</h1>
|
|
|
+ <p>This page demonstrates <code>htmx</code> integration with Astralis.
|
|
|
+ Content updates happen via AJAX without full page reloads.</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Live Clock Example: Auto-updating content with hx-trigger="every 1s" -->
|
|
|
+ <div class="card">
|
|
|
+ <h2>🕐 Live Clock</h2>
|
|
|
+ <p>Auto-updates every second using <code>hx-trigger="every 1s"</code>:</p>
|
|
|
+ <div id="clock-container" class="live-clock"
|
|
|
+ hx-get="/clock"
|
|
|
+ hx-trigger="load, every 1s">
|
|
|
+ Loading...
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Click Counter Example: Button clicks via hx-post -->
|
|
|
+ <div class="card">
|
|
|
+ <h2>👆 Click Counter</h2>
|
|
|
+ <p>Click buttons to update the counter. Uses <code>hx-post</code> and <code>hx-swap="innerHTML"</code>:</p>
|
|
|
+
|
|
|
+ <div class="counter-display">
|
|
|
+ <div class="counter-value" id="counter-value">0</div>
|
|
|
+ <div class="button-group">
|
|
|
+ <button hx-post="/click/decrement"
|
|
|
+ hx-target="#counter-value"
|
|
|
+ hx-swap="innerHTML"
|
|
|
+ class="btn-danger">− Decrement</button>
|
|
|
+ <button hx-post="/click/reset"
|
|
|
+ hx-target="#counter-value"
|
|
|
+ hx-swap="innerHTML"
|
|
|
+ class="btn-warning">↺ Reset</button>
|
|
|
+ <button hx-post="/click/increment"
|
|
|
+ hx-target="#counter-value"
|
|
|
+ hx-swap="innerHTML"
|
|
|
+ class="btn-success">+ Increment</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="info-grid">
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">Last Click</div>
|
|
|
+ <div class="info-value" id="last-click">--:--:--</div>
|
|
|
+ </div>
|
|
|
+ <div class="info-item">
|
|
|
+ <div class="info-label">History</div>
|
|
|
+ <div class="info-value" id="click-history" style="font-size: 12px; font-family: monospace;">No clicks yet</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Update history via hx-get on click -->
|
|
|
+ <div hx-get="/click/history"
|
|
|
+ hx-trigger="click from:.button-group button"
|
|
|
+ hx-target="#click-history"
|
|
|
+ hx-swap="innerHTML"></div>
|
|
|
+ <div hx-get="/click/time"
|
|
|
+ hx-trigger="click from:.button-group button"
|
|
|
+ hx-target="#last-click"
|
|
|
+ hx-swap="innerHTML"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Task List Example: CRUD operations with htmx -->
|
|
|
+ <div class="card">
|
|
|
+ <h2>✅ Task List</h2>
|
|
|
+ <p>A simple task manager with add, toggle, and delete operations:</p>
|
|
|
+
|
|
|
+ <form class="add-task-form" hx-post="/tasks/add" hx-target="#task-list" hx-swap="innerHTML">
|
|
|
+ <input type="text" name="task" placeholder="Enter a new task..." required=""/>
|
|
|
+ <button type="submit" class="btn-primary">Add Task</button>
|
|
|
+ </form>
|
|
|
+
|
|
|
+ <ul class="task-list" id="task-list">
|
|
|
+ <!-- Tasks loaded dynamically -->
|
|
|
+ </ul>
|
|
|
+
|
|
|
+ <div hx-get="/tasks" hx-trigger="load" hx-target="#task-list" hx-swap="innerHTML"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Click Anywhere Example -->
|
|
|
+ <div class="card">
|
|
|
+ <h2>🎯 Click Area</h2>
|
|
|
+ <p>Click anywhere in the area below to record a click. Uses <code>hx-trigger="click"</code>:</p>
|
|
|
+
|
|
|
+ <div class="clicker-area"
|
|
|
+ hx-post="/area/click"
|
|
|
+ hx-trigger="click"
|
|
|
+ hx-target="#area-result"
|
|
|
+ hx-swap="innerHTML">
|
|
|
+ <p>Click anywhere in this area!</p>
|
|
|
+ <div id="area-result" style="margin-top: 10px; font-size: 14px; color: #aaa;">
|
|
|
+ Clicks: 0
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- How It Works -->
|
|
|
+ <div class="card">
|
|
|
+ <h2>How It Works</h2>
|
|
|
+ <p>htmx extends HTML with attributes that enable AJAX directly in your markup:</p>
|
|
|
+
|
|
|
+ <pre><!-- Make a GET request and swap the response -->
|
|
|
+<div hx-get="/api/data" hx-swap="innerHTML">
|
|
|
+ Click to load
|
|
|
+</div>
|
|
|
+
|
|
|
+<!-- POST on click, target another element -->
|
|
|
+<button hx-post="/api/action"
|
|
|
+ hx-target="#result"
|
|
|
+ hx-swap="outerHTML">
|
|
|
+ Submit
|
|
|
+</button>
|
|
|
+
|
|
|
+<!-- Poll every second -->
|
|
|
+<div hx-get="/clock" hx-trigger="every 1s">
|
|
|
+ Loading clock...
|
|
|
+</div></pre>
|
|
|
+
|
|
|
+ <h3>Key htmx Attributes Used:</h3>
|
|
|
+ <ul class="feature-list">
|
|
|
+ <li><code>hx-get</code> - Make a GET request to the specified URL</li>
|
|
|
+ <li><code>hx-post</code> - Make a POST request to the specified URL</li>
|
|
|
+ <li><code>hx-trigger</code> - When to trigger the request (click, load, every Ns)</li>
|
|
|
+ <li><code>hx-target</code> - CSS selector for where to place the response</li>
|
|
|
+ <li><code>hx-swap</code> - How to swap content (innerHTML, outerHTML, beforeend)</li>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <p style="text-align: center; color: #aaa; margin: 0;">
|
|
|
+ Built with <code>Astralis</code> + <code>htmx</code> |
|
|
|
+ <a href="https://htmx.org/" target="_blank" style="color: #3b82f6;">htmx docs</a>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+</body>
|
|
|
+</html>
|
|
|
+""";
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Helper function to render tasks HTML
|
|
|
+string render_tasks_html() {
|
|
|
+ var sb = new StringBuilder();
|
|
|
+ foreach (var task in task_manager.tasks) {
|
|
|
+ string completed_class = task.completed ? "completed" : "";
|
|
|
+ string check_text = task.completed ? "✓" : "○";
|
|
|
+ string check_btn_class = task.completed ? "btn-success" : "btn-warning";
|
|
|
+
|
|
|
+ sb.append(@"<li class=\"task-item $completed_class\">");
|
|
|
+ sb.append(@"<span class=\"task-text\">$(escape_html(task.text))</span>");
|
|
|
+ sb.append(@"<div class=\"task-actions\">");
|
|
|
+ sb.append(@"<button class=\"btn $check_btn_class btn-small\" ");
|
|
|
+ sb.append(@"hx-post=\"/tasks/$(task.id)/toggle\" ");
|
|
|
+ sb.append(@"hx-target=\"#task-list\" ");
|
|
|
+ sb.append(@"hx-swap=\"innerHTML\">$check_text</button> ");
|
|
|
+ sb.append(@"<button class=\"btn btn-danger btn-small\" ");
|
|
|
+ sb.append(@"hx-post=\"/tasks/$(task.id)/delete\" ");
|
|
|
+ sb.append(@"hx-target=\"#task-list\" ");
|
|
|
+ sb.append(@"hx-swap=\"innerHTML\">✕</button>");
|
|
|
+ sb.append(@"</div>");
|
|
|
+ sb.append(@"</li>");
|
|
|
+ }
|
|
|
+ return sb.str;
|
|
|
+}
|
|
|
+
|
|
|
+string escape_html(string text) {
|
|
|
+ var result = text.replace("&", "&");
|
|
|
+ result = result.replace("<", "<");
|
|
|
+ result = result.replace(">", ">");
|
|
|
+ result = result.replace("\"", """);
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+// Main page endpoint
|
|
|
+class HomePageEndpoint : Object, Endpoint {
|
|
|
+ private HtmxTemplate template = Inversion.inject<HtmxTemplate>();
|
|
|
+
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ var doc = template.new_instance();
|
|
|
+ return doc.to_result();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Clock endpoint - returns just the time string
|
|
|
+class ClockEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ var now = new DateTime.now_local();
|
|
|
+ return new HttpStringResult(now.format("%H:%M:%S"), StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Click counter increment endpoint
|
|
|
+class ClickIncrementEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ click_state.record_click("Increment");
|
|
|
+ return new HttpStringResult(click_state.click_count.to_string(), StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Click counter decrement endpoint
|
|
|
+class ClickDecrementEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ click_state.record_click("Decrement");
|
|
|
+ return new HttpStringResult(click_state.click_count.to_string(), StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Click counter reset endpoint
|
|
|
+class ClickResetEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ click_state.record_click("Reset");
|
|
|
+ click_state.click_count = 0;
|
|
|
+ return new HttpStringResult("0", StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Click history endpoint
|
|
|
+class ClickHistoryEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ if (click_state.click_history == "") {
|
|
|
+ return new HttpStringResult("No clicks yet", StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+ // Return first line only for the small display
|
|
|
+ var lines = click_state.click_history.split("\n");
|
|
|
+ string first_line = lines.length > 0 ? lines[0] : "No clicks yet";
|
|
|
+ return new HttpStringResult(first_line, StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Click time endpoint
|
|
|
+class ClickTimeEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ return new HttpStringResult(click_state.last_click.format("%H:%M:%S"), StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Area click counter
|
|
|
+int area_clicks = 0;
|
|
|
+
|
|
|
+class AreaClickEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ area_clicks++;
|
|
|
+ return new HttpStringResult(@"Clicks: $area_clicks", StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Task list endpoint - returns HTML for all tasks
|
|
|
+class TaskListEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ return new HttpStringResult(render_tasks_html(), StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Add task endpoint
|
|
|
+class AddTaskEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route) throws Error {
|
|
|
+ var form_data = yield FormDataParser.parse(
|
|
|
+ context.request.request_body,
|
|
|
+ context.request.content_type
|
|
|
+ );
|
|
|
+
|
|
|
+ string? task_text = form_data.get_field("task");
|
|
|
+
|
|
|
+ if (task_text != null && task_text.strip().length > 0) {
|
|
|
+ task_manager.add_task(task_text.strip());
|
|
|
+ }
|
|
|
+
|
|
|
+ return new HttpStringResult(render_tasks_html(), StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Toggle task endpoint
|
|
|
+class ToggleTaskEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route_info) throws Error {
|
|
|
+ string? id_str = null;
|
|
|
+ route_info.mapped_parameters.try_get("id", out id_str);
|
|
|
+ int task_id = int.parse(id_str ?? "0");
|
|
|
+ task_manager.toggle_task(task_id);
|
|
|
+
|
|
|
+ return new HttpStringResult(render_tasks_html(), StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Delete task endpoint
|
|
|
+class DeleteTaskEndpoint : Object, Endpoint {
|
|
|
+ public async HttpResult handle_request(HttpContext context, RouteContext route_info) throws Error {
|
|
|
+ string? id_str = null;
|
|
|
+ route_info.mapped_parameters.try_get("id", out id_str);
|
|
|
+ int task_id = int.parse(id_str ?? "0");
|
|
|
+ task_manager.delete_task(task_id);
|
|
|
+
|
|
|
+ return new HttpStringResult(render_tasks_html(), StatusCode.OK)
|
|
|
+ .set_header("Content-Type", "text/html; charset=utf-8");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// Global state
|
|
|
+ClickState click_state;
|
|
|
+TaskManager task_manager;
|
|
|
+
|
|
|
+void main(string[] args) {
|
|
|
+ int port = args.length > 1 ? int.parse(args[1]) : 8080;
|
|
|
+
|
|
|
+ // Initialize state
|
|
|
+ click_state = new ClickState();
|
|
|
+ task_manager = new TaskManager();
|
|
|
+
|
|
|
+ print("╔══════════════════════════════════════════════════════════════╗\n");
|
|
|
+ print("║ Astralis htmx Example ║\n");
|
|
|
+ print("╠══════════════════════════════════════════════════════════════╣\n");
|
|
|
+ print(@"║ Port: $port");
|
|
|
+ for (int i = 0; i < 50 - port.to_string().length - 7; i++) print(" ");
|
|
|
+ print(" ║\n");
|
|
|
+ print("╠══════════════════════════════════════════════════════════════╣\n");
|
|
|
+ print("║ Endpoints: ║\n");
|
|
|
+ print("║ / - Main page with htmx examples ║\n");
|
|
|
+ print("║ /styles.css - CSS stylesheet (FastResource) ║\n");
|
|
|
+ print("║ /clock - Live clock (polled every second) ║\n");
|
|
|
+ print("║ /click/* - Click counter endpoints ║\n");
|
|
|
+ print("║ /tasks - Task list (GET all) ║\n");
|
|
|
+ print("║ /tasks/add - Add task (POST) ║\n");
|
|
|
+ print("║ /tasks/{id}/* - Task operations (toggle, delete) ║\n");
|
|
|
+ print("║ /area/click - Click area counter ║\n");
|
|
|
+ print("╠══════════════════════════════════════════════════════════════╣\n");
|
|
|
+ print("║ htmx CDN: ║\n");
|
|
|
+ print("║ https://cdn.jsdelivr.net/npm/htmx.org@2.0.8 ║\n");
|
|
|
+ print("╚══════════════════════════════════════════════════════════════╝\n");
|
|
|
+ print("\nPress Ctrl+C to stop the server\n\n");
|
|
|
+
|
|
|
+ try {
|
|
|
+ var application = new WebApplication(port);
|
|
|
+
|
|
|
+ // Register compression
|
|
|
+ application.use_compression();
|
|
|
+
|
|
|
+ // Register template as singleton
|
|
|
+ application.add_singleton<HtmxTemplate>();
|
|
|
+
|
|
|
+ // Register CSS as FastResource
|
|
|
+ application.add_singleton_endpoint<FastResource>(new EndpointRoute("/styles.css"), () => {
|
|
|
+ try {
|
|
|
+ return new FastResource.from_string(HTMX_CSS)
|
|
|
+ .with_content_type("text/css; charset=utf-8")
|
|
|
+ .with_default_compressors();
|
|
|
+ } catch (Error e) {
|
|
|
+ error("Failed to create CSS resource: %s", e.message);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Main page
|
|
|
+ application.add_endpoint<HomePageEndpoint>(new EndpointRoute("/"));
|
|
|
+
|
|
|
+ // Clock endpoint
|
|
|
+ application.add_endpoint<ClockEndpoint>(new EndpointRoute("/clock"));
|
|
|
+
|
|
|
+ // Click counter endpoints
|
|
|
+ application.add_endpoint<ClickIncrementEndpoint>(new EndpointRoute("/click/increment"));
|
|
|
+ application.add_endpoint<ClickDecrementEndpoint>(new EndpointRoute("/click/decrement"));
|
|
|
+ application.add_endpoint<ClickResetEndpoint>(new EndpointRoute("/click/reset"));
|
|
|
+ application.add_endpoint<ClickHistoryEndpoint>(new EndpointRoute("/click/history"));
|
|
|
+ application.add_endpoint<ClickTimeEndpoint>(new EndpointRoute("/click/time"));
|
|
|
+
|
|
|
+ // Area click endpoint
|
|
|
+ application.add_endpoint<AreaClickEndpoint>(new EndpointRoute("/area/click"));
|
|
|
+
|
|
|
+ // Task endpoints
|
|
|
+ application.add_endpoint<TaskListEndpoint>(new EndpointRoute("/tasks"));
|
|
|
+ application.add_endpoint<AddTaskEndpoint>(new EndpointRoute("/tasks/add"));
|
|
|
+ application.add_endpoint<ToggleTaskEndpoint>(new EndpointRoute("/tasks/{id}/toggle"));
|
|
|
+ application.add_endpoint<DeleteTaskEndpoint>(new EndpointRoute("/tasks/{id}/delete"));
|
|
|
+
|
|
|
+ application.run();
|
|
|
+
|
|
|
+ } catch (Error e) {
|
|
|
+ printerr("Error: %s\n", e.message);
|
|
|
+ Process.exit(1);
|
|
|
+ }
|
|
|
+}
|