|
@@ -0,0 +1,687 @@
|
|
|
|
|
+using Astralis;
|
|
|
|
|
+using Invercargill;
|
|
|
|
|
+using Invercargill.DataStructures;
|
|
|
|
|
+using Inversion;
|
|
|
|
|
+using Spry;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * TemplateExample.vala - Demonstrates PageComponent and PageTemplate usage
|
|
|
|
|
+ *
|
|
|
|
|
+ * This example shows how to create template-based pages where:
|
|
|
|
|
+ * - MainLayoutTemplate provides the base HTML structure with header and footer
|
|
|
|
|
+ * - AdminSectionTemplate adds an admin navigation sidebar (nested template)
|
|
|
|
|
+ * - PageComponents render their content into template outlets
|
|
|
|
|
+ *
|
|
|
|
|
+ * Route structure:
|
|
|
|
|
+ * / -> HomePage (uses MainLayoutTemplate)
|
|
|
|
|
+ * /about -> AboutPage (uses MainLayoutTemplate)
|
|
|
|
|
+ * /admin -> AdminDashboardPage (uses AdminSectionTemplate -> MainLayoutTemplate)
|
|
|
|
|
+ * /admin/users -> AdminUsersPage (uses AdminSectionTemplate -> MainLayoutTemplate)
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+// STYLESHEETS - CSS content served as FastResources
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Main CSS - Base styles for all pages
|
|
|
|
|
+ */
|
|
|
|
|
+private const string MAIN_CSS = """
|
|
|
|
|
+/* Base Reset & Typography */
|
|
|
|
|
+* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
+body {
|
|
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
+ line-height: 1.6;
|
|
|
|
|
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
+ min-height: 100vh;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Header */
|
|
|
|
|
+header {
|
|
|
|
|
+ background: rgba(44, 62, 80, 0.95);
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ padding: 1rem 2rem;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ backdrop-filter: blur(10px);
|
|
|
|
|
+ box-shadow: 0 2px 20px rgba(0,0,0,0.2);
|
|
|
|
|
+}
|
|
|
|
|
+header nav a {
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ margin-right: 1.5rem;
|
|
|
|
|
+ text-decoration: none;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ transition: opacity 0.2s;
|
|
|
|
|
+}
|
|
|
|
|
+header nav a:hover { opacity: 0.8; }
|
|
|
|
|
+header nav a:last-child { margin-right: 0; }
|
|
|
|
|
+header .visit-info {
|
|
|
|
|
+ font-size: 0.85rem;
|
|
|
|
|
+ opacity: 0.8;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Main Content */
|
|
|
|
|
+main.container {
|
|
|
|
|
+ max-width: 1200px;
|
|
|
|
|
+ margin: 2rem auto;
|
|
|
|
|
+ padding: 0 1rem;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Cards */
|
|
|
|
|
+.card {
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ padding: 2rem;
|
|
|
|
|
+ margin-bottom: 1.5rem;
|
|
|
|
|
+ box-shadow: 0 10px 40px rgba(0,0,0,0.15);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Typography */
|
|
|
|
|
+h1 { color: #2c3e50; margin-bottom: 1rem; }
|
|
|
|
|
+h2 { color: #34495e; margin-bottom: 0.75rem; margin-top: 1.5rem; }
|
|
|
|
|
+h3 { color: #34495e; margin-bottom: 0.5rem; margin-top: 1rem; }
|
|
|
|
|
+p { color: #555; margin-bottom: 1rem; }
|
|
|
|
|
+ul, ol { margin-left: 1.5rem; margin-bottom: 1rem; }
|
|
|
|
|
+li { margin-bottom: 0.5rem; color: #555; }
|
|
|
|
|
+
|
|
|
|
|
+/* Links */
|
|
|
|
|
+a { color: #667eea; text-decoration: none; transition: color 0.2s; }
|
|
|
|
|
+a:hover { color: #764ba2; text-decoration: underline; }
|
|
|
|
|
+
|
|
|
|
|
+/* Code */
|
|
|
|
|
+code {
|
|
|
|
|
+ background: #f0f0f0;
|
|
|
|
|
+ padding: 0.2rem 0.5rem;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ font-size: 0.9em;
|
|
|
|
|
+ color: #e74c3c;
|
|
|
|
|
+}
|
|
|
|
|
+pre {
|
|
|
|
|
+ background: #263238;
|
|
|
|
|
+ color: #aed581;
|
|
|
|
|
+ padding: 1rem;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ overflow-x: auto;
|
|
|
|
|
+ font-size: 0.9rem;
|
|
|
|
|
+ margin: 1rem 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Footer */
|
|
|
|
|
+footer {
|
|
|
|
|
+ background: rgba(44, 62, 80, 0.9);
|
|
|
|
|
+ color: rgba(255,255,255,0.7);
|
|
|
|
|
+ padding: 1.5rem;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ margin-top: 2rem;
|
|
|
|
|
+ backdrop-filter: blur(10px);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Buttons & Forms */
|
|
|
|
|
+button {
|
|
|
|
|
+ background: #667eea;
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ padding: 0.75rem 1.5rem;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ font-size: 1rem;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ transition: all 0.2s;
|
|
|
|
|
+}
|
|
|
|
|
+button:hover {
|
|
|
|
|
+ background: #5a6fd6;
|
|
|
|
|
+ transform: translateY(-1px);
|
|
|
|
|
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
|
|
|
+}
|
|
|
|
|
+input[type="text"] {
|
|
|
|
|
+ padding: 0.75rem;
|
|
|
|
|
+ border: 2px solid #e0e0e0;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ font-size: 1rem;
|
|
|
|
|
+ transition: border-color 0.2s;
|
|
|
|
|
+}
|
|
|
|
|
+input[type="text"]:focus {
|
|
|
|
|
+ outline: none;
|
|
|
|
|
+ border-color: #667eea;
|
|
|
|
|
+}
|
|
|
|
|
+""";
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Admin CSS - Additional styles for admin section
|
|
|
|
|
+ */
|
|
|
|
|
+private const string ADMIN_CSS = """
|
|
|
|
|
+/* Admin Section Layout */
|
|
|
|
|
+.admin-section {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 2rem;
|
|
|
|
|
+ margin-top: 1rem;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Admin Sidebar */
|
|
|
|
|
+.admin-sidebar {
|
|
|
|
|
+ width: 220px;
|
|
|
|
|
+ background: rgba(52, 73, 94, 0.95);
|
|
|
|
|
+ padding: 1.5rem;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
|
|
|
|
+ backdrop-filter: blur(10px);
|
|
|
|
|
+}
|
|
|
|
|
+.admin-sidebar h3 {
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ margin-bottom: 1rem;
|
|
|
|
|
+ font-size: 1.1rem;
|
|
|
|
|
+ border-bottom: 1px solid rgba(255,255,255,0.2);
|
|
|
|
|
+ padding-bottom: 0.75rem;
|
|
|
|
|
+}
|
|
|
|
|
+.admin-sidebar ul {
|
|
|
|
|
+ list-style: none;
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.admin-sidebar li { margin-bottom: 0.5rem; }
|
|
|
|
|
+.admin-sidebar a {
|
|
|
|
|
+ color: rgba(255,255,255,0.7);
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ padding: 0.5rem 0.75rem;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ transition: all 0.2s;
|
|
|
|
|
+}
|
|
|
|
|
+.admin-sidebar a:hover {
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ background: rgba(255,255,255,0.1);
|
|
|
|
|
+ text-decoration: none;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Admin Content Area */
|
|
|
|
|
+.admin-content {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Dashboard Stats */
|
|
|
|
|
+.dashboard-stats {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 1.5rem;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+}
|
|
|
|
|
+.stat-card {
|
|
|
|
|
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ padding: 1.5rem;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ min-width: 180px;
|
|
|
|
|
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
|
|
|
|
+}
|
|
|
|
|
+.stat-card h3 {
|
|
|
|
|
+ color: rgba(255,255,255,0.9);
|
|
|
|
|
+ font-size: 0.85rem;
|
|
|
|
|
+ text-transform: uppercase;
|
|
|
|
|
+ letter-spacing: 1px;
|
|
|
|
|
+ margin: 0 0 0.5rem 0;
|
|
|
|
|
+}
|
|
|
|
|
+.stat-card .stat-value {
|
|
|
|
|
+ font-size: 2.5rem;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* User List */
|
|
|
|
|
+.user-list {
|
|
|
|
|
+ margin: 1.5rem 0;
|
|
|
|
|
+}
|
|
|
|
|
+.user-item {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 1rem;
|
|
|
|
|
+ padding: 1rem 1.25rem;
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ margin-bottom: 0.75rem;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
|
|
|
+ transition: transform 0.2s, box-shadow 0.2s;
|
|
|
|
|
+}
|
|
|
|
|
+.user-item:hover {
|
|
|
|
|
+ transform: translateX(4px);
|
|
|
|
|
+ box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
|
|
|
|
+}
|
|
|
|
|
+.user-item .user-name {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ color: #2c3e50;
|
|
|
|
|
+}
|
|
|
|
|
+.user-item .user-status {
|
|
|
|
|
+ color: #27ae60;
|
|
|
|
|
+ font-size: 0.9rem;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Add Form */
|
|
|
|
|
+.add-form {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 0.75rem;
|
|
|
|
|
+ margin-top: 1.5rem;
|
|
|
|
|
+}
|
|
|
|
|
+.add-form input[type="text"] {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ max-width: 300px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* Navigation Links */
|
|
|
|
|
+.nav-link {
|
|
|
|
|
+ display: inline-block;
|
|
|
|
|
+ margin-top: 1.5rem;
|
|
|
|
|
+ color: #667eea;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+}
|
|
|
|
|
+.nav-link:hover { color: #764ba2; }
|
|
|
|
|
+""";
|
|
|
|
|
+
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+// STORES - Simple data stores for demonstration
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+
|
|
|
|
|
+class AppState : Object {
|
|
|
|
|
+ public int visit_count { get; set; }
|
|
|
|
|
+ public string last_visit { get; set; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class UserStore : Object {
|
|
|
|
|
+ private Series<string> users = new Series<string>();
|
|
|
|
|
+
|
|
|
|
|
+ construct {
|
|
|
|
|
+ users.add("Alice");
|
|
|
|
|
+ users.add("Bob");
|
|
|
|
|
+ users.add("Charlie");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public Enumerable<string> get_all() {
|
|
|
|
|
+ return users;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public void add_user(string name) {
|
|
|
|
|
+ users.add(name);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public int count() {
|
|
|
|
|
+ return (int)users.length;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+// TEMPLATES - Provide reusable page layouts
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * MainLayoutTemplate - The base template for all pages
|
|
|
|
|
+ *
|
|
|
|
|
+ * Provides:
|
|
|
|
|
+ * - HTML document structure
|
|
|
|
|
+ * - Common <head> elements (scripts, styles)
|
|
|
|
|
+ * - Site-wide header with navigation
|
|
|
|
|
+ * - Site-wide footer
|
|
|
|
|
+ *
|
|
|
|
|
+ * Uses <spry-template-outlet> where child content/templates will be inserted
|
|
|
|
|
+ */
|
|
|
|
|
+class MainLayoutTemplate : PageTemplate {
|
|
|
|
|
+
|
|
|
|
|
+ private AppState app_state = inject<AppState>();
|
|
|
|
|
+
|
|
|
|
|
+ public override string markup { get {
|
|
|
|
|
+ return """
|
|
|
|
|
+ <!DOCTYPE html>
|
|
|
|
|
+ <html lang="en">
|
|
|
|
|
+ <head>
|
|
|
|
|
+ <meta charset="UTF-8">
|
|
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+ <title>Spry Template Example</title>
|
|
|
|
|
+ <link rel="stylesheet" href="/styles/main.css">
|
|
|
|
|
+ <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
|
|
|
|
|
+ </head>
|
|
|
|
|
+ <body>
|
|
|
|
|
+ <header>
|
|
|
|
|
+ <nav>
|
|
|
|
|
+ <a href="/">Home</a>
|
|
|
|
|
+ <a href="/about">About</a>
|
|
|
|
|
+ <a href="/admin">Admin</a>
|
|
|
|
|
+ </nav>
|
|
|
|
|
+ <small class="visit-info" sid="visit-info"></small>
|
|
|
|
|
+ </header>
|
|
|
|
|
+ <main class="container">
|
|
|
|
|
+ <spry-template-outlet />
|
|
|
|
|
+ </main>
|
|
|
|
|
+ <footer>
|
|
|
|
|
+ <p>Built with Spry Framework - Template Example</p>
|
|
|
|
|
+ </footer>
|
|
|
|
|
+ </body>
|
|
|
|
|
+ </html>
|
|
|
|
|
+ """;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ public override async void prepare() throws Error {
|
|
|
|
|
+ this["visit-info"].text_content = @"Visits: $(app_state.visit_count) | Last: $(app_state.last_visit)";
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * AdminSectionTemplate - A nested template for admin pages
|
|
|
|
|
+ *
|
|
|
|
|
+ * This template is applied to routes starting with /admin
|
|
|
|
|
+ * It adds an admin sidebar navigation around the page content
|
|
|
|
|
+ *
|
|
|
|
|
+ * Templates are applied in order of rank (based on prefix length):
|
|
|
|
|
+ * 1. MainLayoutTemplate (rank 0, prefix "") - outermost
|
|
|
|
|
+ * 2. AdminSectionTemplate (rank 1, prefix "/admin") - nested inside main
|
|
|
|
|
+ * 3. PageComponent - innermost content
|
|
|
|
|
+ */
|
|
|
|
|
+class AdminSectionTemplate : PageTemplate {
|
|
|
|
|
+
|
|
|
|
|
+ public override string markup { get {
|
|
|
|
|
+ return """
|
|
|
|
|
+ <head>
|
|
|
|
|
+ <link rel="stylesheet" href="/styles/admin.css">
|
|
|
|
|
+ </head>
|
|
|
|
|
+ <div class="admin-section">
|
|
|
|
|
+ <aside class="admin-sidebar">
|
|
|
|
|
+ <h3>Admin Menu</h3>
|
|
|
|
|
+ <ul>
|
|
|
|
|
+ <li><a href="/admin">Dashboard</a></li>
|
|
|
|
|
+ <li><a href="/admin/users">Users</a></li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </aside>
|
|
|
|
|
+ <div class="admin-content">
|
|
|
|
|
+ <spry-template-outlet />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ """;
|
|
|
|
|
+ }}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+// PAGE COMPONENTS - Render content into templates
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * HomePage - The main landing page
|
|
|
|
|
+ *
|
|
|
|
|
+ * Extends PageComponent (not Component) to automatically use template rendering
|
|
|
|
|
+ * The handle_request method in PageComponent:
|
|
|
|
|
+ * 1. Finds all matching templates (MainLayoutTemplate matches "")
|
|
|
|
|
+ * 2. Renders each template in order
|
|
|
|
|
+ * 3. Inserts each rendered content into the previous template's outlet
|
|
|
|
|
+ */
|
|
|
|
|
+class HomePage : PageComponent {
|
|
|
|
|
+
|
|
|
|
|
+ private AppState app_state = inject<AppState>();
|
|
|
|
|
+
|
|
|
|
|
+ public const string ROUTE = "/";
|
|
|
|
|
+
|
|
|
|
|
+ public override string markup { get {
|
|
|
|
|
+ return """
|
|
|
|
|
+ <div class="card" sid="home">
|
|
|
|
|
+ <h1>Welcome to Spry!</h1>
|
|
|
|
|
+ <p>This example demonstrates template-based pages using <code>PageComponent</code> and <code>PageTemplate</code>.</p>
|
|
|
|
|
+
|
|
|
|
|
+ <h2>Features</h2>
|
|
|
|
|
+ <ul>
|
|
|
|
|
+ <li><strong>MainLayoutTemplate</strong> - Provides base HTML, header, footer</li>
|
|
|
|
|
+ <li><strong>AdminSectionTemplate</strong> - Adds sidebar for /admin/* routes</li>
|
|
|
|
|
+ <li><strong>PageComponent</strong> - Pages automatically inherit templates</li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+
|
|
|
|
|
+ <h2>Try These Pages</h2>
|
|
|
|
|
+ <ul>
|
|
|
|
|
+ <li><a href="/about">About Page</a> - Uses main layout only</li>
|
|
|
|
|
+ <li><a href="/admin">Admin Dashboard</a> - Uses admin template + main layout</li>
|
|
|
|
|
+ <li><a href="/admin/users">Admin Users</a> - Interactive user list</li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+
|
|
|
|
|
+ <p sid="visit-count"></p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ """;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ public override async void prepare() throws Error {
|
|
|
|
|
+ app_state.visit_count++;
|
|
|
|
|
+ app_state.last_visit = new DateTime.now_local().format("%H:%M:%S");
|
|
|
|
|
+ this["visit-count"].text_content = @"You have visited $(app_state.visit_count) times.";
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * AboutPage - A simple about page
|
|
|
|
|
+ */
|
|
|
|
|
+class AboutPage : PageComponent {
|
|
|
|
|
+
|
|
|
|
|
+ public const string ROUTE = "/about";
|
|
|
|
|
+
|
|
|
|
|
+ public override string markup { get {
|
|
|
|
|
+ return """
|
|
|
|
|
+ <div class="card">
|
|
|
|
|
+ <h1>About Spry Templates</h1>
|
|
|
|
|
+
|
|
|
|
|
+ <h2>How Templates Work</h2>
|
|
|
|
|
+ <p>Templates use <code><spry-template-outlet /></code> to define where child content will be inserted.</p>
|
|
|
|
|
+
|
|
|
|
|
+ <h3>Template Resolution</h3>
|
|
|
|
|
+ <p>When a <code>PageComponent</code> handles a request:</p>
|
|
|
|
|
+ <ol>
|
|
|
|
|
+ <li>It finds all registered <code>PageTemplate</code>s matching the route</li>
|
|
|
|
|
+ <li>Templates are ordered by their prefix length (rank)</li>
|
|
|
|
|
+ <li>Each template is rendered, with content nested into outlets</li>
|
|
|
|
|
+ </ol>
|
|
|
|
|
+
|
|
|
|
|
+ <h3>Route Matching</h3>
|
|
|
|
|
+ <pre>
|
|
|
|
|
+Prefix "" matches all routes
|
|
|
|
|
+Prefix "/admin" matches /admin and /admin/*
|
|
|
|
|
+Prefix "/admin/users" matches /admin/users only
|
|
|
|
|
+ </pre>
|
|
|
|
|
+
|
|
|
|
|
+ <a href="/" class="nav-link">Back to Home</a>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ """;
|
|
|
|
|
+ }}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * AdminDashboardPage - Admin dashboard with statistics
|
|
|
|
|
+ */
|
|
|
|
|
+class AdminDashboardPage : PageComponent {
|
|
|
|
|
+
|
|
|
|
|
+ private UserStore user_store = inject<UserStore>();
|
|
|
|
|
+
|
|
|
|
|
+ public const string ROUTE = "/admin";
|
|
|
|
|
+
|
|
|
|
|
+ public override string markup { get {
|
|
|
|
|
+ return """
|
|
|
|
|
+ <div class="card">
|
|
|
|
|
+ <h1>Admin Dashboard</h1>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="dashboard-stats">
|
|
|
|
|
+ <div class="stat-card">
|
|
|
|
|
+ <h3>Total Users</h3>
|
|
|
|
|
+ <div class="stat-value" sid="user-count"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <a href="/" class="nav-link">Back to Home</a>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ """;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ public override async void prepare() throws Error {
|
|
|
|
|
+ this["user-count"].text_content = user_store.count().to_string();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * AdminUsersPage - Admin page with interactive user list
|
|
|
|
|
+ *
|
|
|
|
|
+ * Demonstrates HTMX interactions within a templated page
|
|
|
|
|
+ */
|
|
|
|
|
+class AdminUsersPage : PageComponent {
|
|
|
|
|
+
|
|
|
|
|
+ private UserStore user_store = inject<UserStore>();
|
|
|
|
|
+ private ComponentFactory factory = inject<ComponentFactory>();
|
|
|
|
|
+ private HttpContext http_context = inject<HttpContext>();
|
|
|
|
|
+
|
|
|
|
|
+ public const string ROUTE = "/admin/users";
|
|
|
|
|
+
|
|
|
|
|
+ public override string markup { get {
|
|
|
|
|
+ return """
|
|
|
|
|
+ <div class="card">
|
|
|
|
|
+ <h1>User Management</h1>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="user-list" sid="user-list">
|
|
|
|
|
+ <!-- Users rendered here -->
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <form class="add-form" sid="add-form" spry-action=":AddUser" spry-target="user-list" hx-swap="innerHTML">
|
|
|
|
|
+ <input type="text" name="username" placeholder="Enter username" required>
|
|
|
|
|
+ <button type="submit">Add User</button>
|
|
|
|
|
+ </form>
|
|
|
|
|
+
|
|
|
|
|
+ <a href="/admin" class="nav-link">Back to Dashboard</a>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ """;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ public override async void prepare() throws Error {
|
|
|
|
|
+ refresh_user_list();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void refresh_user_list() throws Error {
|
|
|
|
|
+ var users = user_store.get_all();
|
|
|
|
|
+ var items = new Series<Renderable>();
|
|
|
|
|
+
|
|
|
|
|
+ users.iterate((name) => {
|
|
|
|
|
+ var item = factory.create<UserItemComponent>();
|
|
|
|
|
+ item.username = name;
|
|
|
|
|
+ items.add(item);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ set_outlet_children("user-list", items);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public async override void handle_action(string action) throws Error {
|
|
|
|
|
+ if (action == "AddUser") {
|
|
|
|
|
+ var username = http_context.request.query_params.get_any_or_default("username");
|
|
|
|
|
+ if (username != null && username.length > 0) {
|
|
|
|
|
+ user_store.add_user(username);
|
|
|
|
|
+ }
|
|
|
|
|
+ refresh_user_list();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * UserItemComponent - Individual user item in the list
|
|
|
|
|
+ */
|
|
|
|
|
+class UserItemComponent : Component {
|
|
|
|
|
+
|
|
|
|
|
+ public string username { set; get; }
|
|
|
|
|
+
|
|
|
|
|
+ public override string markup { get {
|
|
|
|
|
+ return """
|
|
|
|
|
+ <div class="user-item" sid="user-item">
|
|
|
|
|
+ <span class="user-name" sid="name"></span>
|
|
|
|
|
+ <span class="user-status">Active</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ """;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ public override async void prepare() throws Error {
|
|
|
|
|
+ this["name"].text_content = username;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+// APPLICATION SETUP
|
|
|
|
|
+// =============================================================================
|
|
|
|
|
+
|
|
|
|
|
+void main(string[] args) {
|
|
|
|
|
+ int port = args.length > 1 ? int.parse(args[1]) : 8080;
|
|
|
|
|
+
|
|
|
|
|
+ print("═══════════════════════════════════════════════════════════════\n");
|
|
|
|
|
+ print(" Spry Template Example (PageComponent + PageTemplate)\n");
|
|
|
|
|
+ print("═══════════════════════════════════════════════════════════════\n");
|
|
|
|
|
+ print(" Port: %d\n", port);
|
|
|
|
|
+ print("═══════════════════════════════════════════════════════════════\n");
|
|
|
|
|
+ print(" Endpoints:\n");
|
|
|
|
|
+ print(" / - Home page (MainLayoutTemplate)\n");
|
|
|
|
|
+ print(" /about - About page (MainLayoutTemplate)\n");
|
|
|
|
|
+ print(" /admin - Admin dashboard (AdminSectionTemplate)\n");
|
|
|
|
|
+ print(" /admin/users - User management (AdminSectionTemplate)\n");
|
|
|
|
|
+ print(" /styles/main.css - Main stylesheet (FastResource)\n");
|
|
|
|
|
+ print(" /styles/admin.css - Admin stylesheet (FastResource)\n");
|
|
|
|
|
+ print("═══════════════════════════════════════════════════════════════\n");
|
|
|
|
|
+ print("\nPress Ctrl+C to stop the server\n\n");
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ var application = new WebApplication(port);
|
|
|
|
|
+
|
|
|
|
|
+ // Enable compression
|
|
|
|
|
+ application.use_compression();
|
|
|
|
|
+
|
|
|
|
|
+ // Add Spry module for component actions
|
|
|
|
|
+ application.add_module<SpryModule>();
|
|
|
|
|
+
|
|
|
|
|
+ // Register stores as singletons
|
|
|
|
|
+ application.add_singleton<AppState>();
|
|
|
|
|
+ application.add_singleton<UserStore>();
|
|
|
|
|
+
|
|
|
|
|
+ // Register templates with route prefixes
|
|
|
|
|
+ // MainLayoutTemplate applies to ALL routes (empty prefix)
|
|
|
|
|
+ application.add_transient<MainLayoutTemplate>()
|
|
|
|
|
+ .as<PageTemplate>()
|
|
|
|
|
+ .with_metadata(new TemplateRoutePrefix(""));
|
|
|
|
|
+
|
|
|
|
|
+ // AdminSectionTemplate applies to /admin/* routes
|
|
|
|
|
+ application.add_transient<AdminSectionTemplate>()
|
|
|
|
|
+ .as<PageTemplate>()
|
|
|
|
|
+ .with_metadata(new TemplateRoutePrefix("/admin"));
|
|
|
|
|
+
|
|
|
|
|
+ // Register page components as endpoints
|
|
|
|
|
+ application.add_transient<HomePage>();
|
|
|
|
|
+ application.add_endpoint<HomePage>(new EndpointRoute(HomePage.ROUTE));
|
|
|
|
|
+
|
|
|
|
|
+ application.add_transient<AboutPage>();
|
|
|
|
|
+ application.add_endpoint<AboutPage>(new EndpointRoute(AboutPage.ROUTE));
|
|
|
|
|
+
|
|
|
|
|
+ application.add_transient<AdminDashboardPage>();
|
|
|
|
|
+ application.add_endpoint<AdminDashboardPage>(new EndpointRoute(AdminDashboardPage.ROUTE));
|
|
|
|
|
+
|
|
|
|
|
+ application.add_transient<AdminUsersPage>();
|
|
|
|
|
+ application.add_endpoint<AdminUsersPage>(new EndpointRoute(AdminUsersPage.ROUTE));
|
|
|
|
|
+
|
|
|
|
|
+ // Register child components
|
|
|
|
|
+ application.add_transient<UserItemComponent>();
|
|
|
|
|
+
|
|
|
|
|
+ // Register CSS as FastResources
|
|
|
|
|
+ application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles/main.css"), () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return new FastResource.from_string(MAIN_CSS)
|
|
|
|
|
+ .with_content_type("text/css; charset=utf-8")
|
|
|
|
|
+ .with_default_compressors();
|
|
|
|
|
+ } catch (Error e) {
|
|
|
|
|
+ error("Failed to create main CSS resource: %s", e.message);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles/admin.css"), () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return new FastResource.from_string(ADMIN_CSS)
|
|
|
|
|
+ .with_content_type("text/css; charset=utf-8")
|
|
|
|
|
+ .with_default_compressors();
|
|
|
|
|
+ } catch (Error e) {
|
|
|
|
|
+ error("Failed to create admin CSS resource: %s", e.message);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ application.run();
|
|
|
|
|
+
|
|
|
|
|
+ } catch (Error e) {
|
|
|
|
|
+ printerr("Error: %s\n", e.message);
|
|
|
|
|
+ Process.exit(1);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|