feat(examples): add saas tutorial skeleton
Add a compile-tested v1 tutorial skeleton that proves the production-shaped app boundary without provider-heavy scope: auth/session context, CSRF-checked mutation, local persistence adapter, generated form/slot/keyed row/page/push effects, plain CSS, SSE shape, and one explicit metrics island. req: examples/001 req: auth/001 req: auth/002 req: auth/004 req: form/001 req: failure/004 req: page_swap/002 req: push/003
This commit is contained in:
Generated
+15
@@ -489,6 +489,21 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hemx-saas-example"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures-util",
|
||||
"hemplate",
|
||||
"hemx",
|
||||
"hemx-axum",
|
||||
"hemx-build",
|
||||
"hemx-test",
|
||||
"scraper",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hemx-techdemo"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo"]
|
||||
members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -115,6 +115,9 @@ wire/runtime ABI; and advanced escape hatches that may remain integration-level.
|
||||
- `examples/v0`: canonical beginner path covering counter, typed todo CRUD,
|
||||
form wizard, auth action, page swaps, SSE notifications, and keyed list
|
||||
updates. Start here.
|
||||
- `examples/saas`: compile-tested v1 tutorial skeleton covering auth/session,
|
||||
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
|
||||
CSS, and one explicit island without provider-heavy platform scope.
|
||||
- `examples/kanban`: advanced / north-star milestone boundary sketch. It may
|
||||
expose manual registry or render escape hatches while exploring product limits.
|
||||
- `examples/techdemo`: advanced integration demo with a leaf island and broader
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "hemx-saas-example"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "hemx-saas-example"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
futures-util = "0.3"
|
||||
hemplate = { path = "../../../hemplate/hemplate" }
|
||||
hemx = { path = "../../hemx" }
|
||||
hemx-axum = { path = "../../hemx-axum" }
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
scraper = "0.23"
|
||||
hemx-test = { path = "../../hemx-test" }
|
||||
|
||||
[build-dependencies]
|
||||
hemx-build = { path = "../../hemx-build" }
|
||||
@@ -0,0 +1,27 @@
|
||||
# hemx SaaS tutorial skeleton
|
||||
|
||||
This is the compile-tested skeleton for the v1 production-shaped tutorial app. It is intentionally provider-light: auth/session, CSRF, persistence, deploy, metrics, and islands are explicit app boundaries, not hemx core services. req: examples/001 req: auth/001
|
||||
|
||||
What it proves today:
|
||||
|
||||
- typed form/newtype inputs for project creation
|
||||
- auth/session context passed through normal Rust state
|
||||
- CSRF-safe mutation checked before persistence
|
||||
- local in-memory persistence adapter instead of a vendored SQL/auth provider
|
||||
- generated form, slot, keyed row, page-swap, and live-status commands
|
||||
- page shell with plain CSS and one explicit metrics island script
|
||||
- compile-time surface generation plus interaction tests
|
||||
|
||||
What it deliberately does not claim yet:
|
||||
|
||||
- real SQLx migrations or a database pool
|
||||
- production cookie/session middleware
|
||||
- a deploy target, flags, analytics, billing, or offline sync
|
||||
- browser automation for the metrics island
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
cargo run -p hemx-saas-example
|
||||
cargo test -p hemx-saas-example
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
hemx_build::app().run().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
#[hemx::surface]
|
||||
pub mod ui {}
|
||||
|
||||
use hemplate::Hemplate;
|
||||
use hemx::{push, Html, IntoEffect};
|
||||
use hemx_axum::{
|
||||
interactions, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure, Registry, State,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use std::fmt::{Display, Write as _};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ui::dashboard;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Session {
|
||||
user_id: UserId,
|
||||
email: String,
|
||||
csrf: CsrfToken,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn demo() -> Self {
|
||||
Self {
|
||||
user_id: UserId(42),
|
||||
email: "founder@example.com".to_owned(),
|
||||
csrf: CsrfToken("demo-csrf".to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct UserId(u64);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CsrfToken(String);
|
||||
|
||||
impl FromStr for CsrfToken {
|
||||
type Err = Infallible;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(value.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CsrfToken {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ProjectName(String);
|
||||
|
||||
impl ProjectName {
|
||||
fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ProjectName {
|
||||
type Err = Infallible;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(value.trim().to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[hemx::form("new_project")]
|
||||
pub struct NewProject {
|
||||
csrf: CsrfToken,
|
||||
name: ProjectName,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ProjectId(u64);
|
||||
|
||||
impl Display for ProjectId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ProjectRecord {
|
||||
id: ProjectId,
|
||||
name: String,
|
||||
owner: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct LocalProjectStore {
|
||||
projects: Arc<Mutex<Vec<ProjectRecord>>>,
|
||||
}
|
||||
|
||||
impl LocalProjectStore {
|
||||
pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> {
|
||||
if name.as_str() == "fail-store" {
|
||||
return Err(AppError::StoreUnavailable);
|
||||
}
|
||||
|
||||
let mut projects = self.projects.lock().unwrap();
|
||||
let id = ProjectId(projects.last().map_or(1, |project| project.id.0 + 1));
|
||||
let record = ProjectRecord {
|
||||
id,
|
||||
name: name.as_str().to_owned(),
|
||||
owner: session.email.clone(),
|
||||
};
|
||||
projects.push(record.clone());
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<ProjectRecord> {
|
||||
self.projects.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppContext {
|
||||
session: Session,
|
||||
store: LocalProjectStore,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
pub fn demo() -> Self {
|
||||
Self {
|
||||
session: Session::demo(),
|
||||
store: LocalProjectStore::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn csrf(&self) -> &CsrfToken {
|
||||
&self.session.csrf
|
||||
}
|
||||
|
||||
pub fn projects(&self) -> Vec<ProjectRecord> {
|
||||
self.store.list()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
MissingSession,
|
||||
CsrfRejected,
|
||||
StoreUnavailable,
|
||||
Validation(&'static str),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
fn message(&self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingSession => "Sign in to continue",
|
||||
Self::CsrfRejected => "Refresh the page before creating another project",
|
||||
Self::StoreUnavailable => "Project storage is temporarily unavailable",
|
||||
Self::Validation(message) => message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoHandlerFailure for AppError {
|
||||
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
|
||||
match self {
|
||||
Self::Validation(message) => HandlerFailure::effects(
|
||||
(
|
||||
dashboard::new_project.error("name", message),
|
||||
dashboard::new_project.focus("name"),
|
||||
),
|
||||
context,
|
||||
),
|
||||
other => HandlerFailure::effects(dashboard::flash.set(other.message()), context),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Dashboard {
|
||||
csrf: CsrfToken,
|
||||
flash: String,
|
||||
summary: String,
|
||||
rows: Vec<ProjectRow>,
|
||||
project_count: usize,
|
||||
}
|
||||
|
||||
impl Dashboard {
|
||||
pub fn from_context(ctx: &AppContext) -> Self {
|
||||
let projects = ctx.projects();
|
||||
Self {
|
||||
csrf: ctx.csrf().clone(),
|
||||
flash: "Signed in with a demo session".to_owned(),
|
||||
summary: project_summary(projects.len()),
|
||||
project_count: projects.len(),
|
||||
rows: projects.into_iter().map(ProjectRow::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
#[hemplate = "partials"]
|
||||
pub struct ProjectRow {
|
||||
id: ProjectId,
|
||||
name: String,
|
||||
owner: String,
|
||||
}
|
||||
|
||||
impl Hemplate for Dashboard {
|
||||
fn render_into(&self, buf: &mut String) -> Result<(), hemplate::error::HemplateError> {
|
||||
buf.push_str("<section class=\"dashboard\" data-hemx-root=\"dashboard\" data-hemx-sse=\"/events\">\n");
|
||||
buf.push_str(" <header class=\"hero\"><p class=\"eyebrow\">Production-shaped SaaS path</p><h1>Projects</h1><p class=\"lede\">Auth-gated mutations, CSRF checks, local persistence, typed forms, generated swaps, page swaps, live status, plain CSS, and one explicit island.</p></header>\n");
|
||||
buf.push_str(" <nav class=\"tabs\" data-hemx-slot=\"nav\"><a href=\"/\" data-hemx-nav=\"\">Projects</a><button type=\"button\" data-hemx-handle=\"open_settings\">Settings</button></nav>\n");
|
||||
buf.push_str(" <section class=\"panel\" data-hemx-slot=\"page_panel\">\n");
|
||||
buf.push_str(" <form class=\"project-form\" data-hemx-handle=\"create_project\" data-hemx-form=\"new_project\" data-hemx-disable-while-pending>\n");
|
||||
write!(
|
||||
buf,
|
||||
" <input type=\"hidden\" name=\"csrf\" value=\"{}\">\n",
|
||||
hemplate::HtmlEscape(&self.csrf.to_string())
|
||||
)?;
|
||||
buf.push_str(" <label>Project name <input name=\"name\" required=\"required\" maxlength=\"64\" value=\"Launch checklist\"></label><button type=\"submit\">Create project</button><p class=\"field-error\" data-hemx-error-for=\"name\"></p>\n </form>\n");
|
||||
write!(
|
||||
buf,
|
||||
" <p class=\"flash\" data-hemx-slot=\"flash\">{}</p>\n",
|
||||
hemplate::HtmlEscape(&self.flash)
|
||||
)?;
|
||||
write!(
|
||||
buf,
|
||||
" <p class=\"summary\" data-hemx-slot=\"summary\">{}</p>\n",
|
||||
hemplate::HtmlEscape(&self.summary)
|
||||
)?;
|
||||
buf.push_str(" <ul class=\"project-list\" data-hemx-slot=\"project_row\">\n");
|
||||
for row in &self.rows {
|
||||
buf.push_str(" ");
|
||||
row.render_into(buf)?;
|
||||
buf.push('\n');
|
||||
}
|
||||
buf.push_str(" </ul>\n </section>\n <section class=\"status-row\"><p data-hemx-slot=\"live_status\">Waiting for status…</p>");
|
||||
write!(buf, "<article class=\"metrics-island\" data-hemx-island=\"metrics\" data-project-count=\"{}\"><h2>Metrics island</h2><canvas width=\"320\" height=\"140\" aria-label=\"Project metrics chart\"></canvas><p data-island-readout=\"\">Waiting for island script…</p></article>", self.project_count)?;
|
||||
buf.push_str("</section>\n</section>\n");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProjectRecord> for ProjectRow {
|
||||
fn from(record: ProjectRecord) -> Self {
|
||||
Self {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
owner: record.owner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl hemx::KeyedPartial for ProjectRow {
|
||||
fn hemx_key(&self) -> String {
|
||||
self.id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
#[hemplate = "partials"]
|
||||
pub struct SettingsPage {
|
||||
message: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
pub struct AppShell {
|
||||
title: &'static str,
|
||||
body: Html,
|
||||
}
|
||||
|
||||
pub fn home_page(ctx: &AppContext) -> Html {
|
||||
hemx::page(&AppShell {
|
||||
title: "hemx SaaS tutorial",
|
||||
body: hemx::page(&Dashboard::from_context(ctx)),
|
||||
})
|
||||
}
|
||||
|
||||
#[hemx::app(dashboard_handlers)]
|
||||
pub fn registry(ctx: AppContext) -> Registry {
|
||||
interactions(ui::BUILD_FINGERPRINT)
|
||||
}
|
||||
|
||||
#[hemx::component("dashboard")]
|
||||
mod dashboard_handlers {
|
||||
use super::*;
|
||||
|
||||
#[hemx::handler]
|
||||
pub async fn create_project(
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<NewProject>,
|
||||
) -> Result<impl IntoEffect, AppError> {
|
||||
if ctx.session.email.is_empty() {
|
||||
return Err(AppError::MissingSession);
|
||||
}
|
||||
if form.csrf != ctx.session.csrf {
|
||||
return Err(AppError::CsrfRejected);
|
||||
}
|
||||
if form.name.as_str().is_empty() {
|
||||
return Err(AppError::Validation("Project name required"));
|
||||
}
|
||||
|
||||
let project = ctx.store.insert(form.name, &ctx.session)?;
|
||||
let total = ctx.projects().len();
|
||||
Ok((
|
||||
dashboard::project_row.append(ProjectRow::from(project)),
|
||||
dashboard::summary.set(project_summary(total)),
|
||||
dashboard::new_project.clear(),
|
||||
dashboard::flash.set("Project created"),
|
||||
dashboard::live_status.set(format!("{total} projects persisted locally")),
|
||||
))
|
||||
}
|
||||
|
||||
#[hemx::handler]
|
||||
pub async fn open_settings(State(_ctx): State<AppContext>) -> impl IntoEffect {
|
||||
(
|
||||
dashboard::page_panel.put(&SettingsPage {
|
||||
message:
|
||||
"Auth, CSRF, persistence, metrics, and deploy stay explicit app integrations.",
|
||||
}),
|
||||
dashboard::nav.set("Settings"),
|
||||
push("/settings"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn live_status(projects: usize) -> impl IntoEffect {
|
||||
dashboard::live_status.set(format!("heartbeat: {projects} projects"))
|
||||
}
|
||||
|
||||
fn project_summary(total: usize) -> String {
|
||||
match total {
|
||||
0 => "No projects yet".to_owned(),
|
||||
1 => "1 project".to_owned(),
|
||||
total => format!("{total} projects"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hemx_axum::{InteractionForm, InteractionRequest};
|
||||
use hemx_test::{inspect, inspect_batch};
|
||||
use scraper::{Html as ParsedHtml, Selector};
|
||||
|
||||
fn form<I>(handle: hemx::Handle<I>, fields: &[(&str, &str)]) -> InteractionForm {
|
||||
InteractionForm::for_handle(
|
||||
handle,
|
||||
fields
|
||||
.iter()
|
||||
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned())),
|
||||
)
|
||||
}
|
||||
|
||||
fn selector(value: &str) -> Selector {
|
||||
Selector::parse(value).expect("test selector parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_page_documents_the_production_skeleton_boundaries() {
|
||||
// req: examples/001 req: auth/001 req: auth/004 req: interop/003
|
||||
let ctx = AppContext::demo();
|
||||
let html = home_page(&ctx);
|
||||
let document = ParsedHtml::parse_document(html.as_str());
|
||||
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector("[data-hemx-root='dashboard']"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector("form[data-hemx-form='new_project']"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(document.select(&selector("input[name='csrf']")).count(), 1);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector("[data-hemx-sse='/events']"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector("[data-hemx-island='metrics']"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(html.as_str().contains("/app.css"));
|
||||
assert!(html.as_str().contains("/metrics.js"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_project_is_auth_csrf_checked_and_persisted_locally() {
|
||||
// req: examples/001 req: auth/002 req: auth/004 req: form/001 req: failure/004
|
||||
let ctx = AppContext::demo();
|
||||
|
||||
let rejected = inspect_batch(
|
||||
InteractionRequest::from(form(
|
||||
dashboard::create_project,
|
||||
&[("csrf", "stale"), ("name", "Launch checklist")],
|
||||
))
|
||||
.dispatch_async(registry(ctx.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(ctx.projects().is_empty());
|
||||
assert!(rejected.updates_text(dashboard::flash));
|
||||
assert!(rejected.payload_contains("Refresh the page"));
|
||||
|
||||
let validation = inspect_batch(
|
||||
InteractionRequest::from(form(
|
||||
dashboard::create_project,
|
||||
&[("csrf", "demo-csrf"), ("name", " ")],
|
||||
))
|
||||
.dispatch_async(registry(ctx.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(ctx.projects().is_empty());
|
||||
assert!(validation.payload_contains("Project name required"));
|
||||
|
||||
let created = inspect_batch(
|
||||
InteractionRequest::from(form(
|
||||
dashboard::create_project,
|
||||
&[("csrf", "demo-csrf"), ("name", "Launch checklist")],
|
||||
))
|
||||
.dispatch_async(registry(ctx.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert_eq!(ctx.projects()[0].name, "Launch checklist");
|
||||
assert!(created.inserts_html_containing(dashboard::project_row, "1", "Launch checklist"));
|
||||
assert!(created.updates_text(dashboard::summary));
|
||||
assert!(created.resets_form(dashboard::new_project));
|
||||
assert!(created.updates_text(dashboard::live_status));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_swap_and_push_shape_use_generated_targets() {
|
||||
// req: page_swap/002 req: push/003 req: examples/001
|
||||
let ctx = AppContext::demo();
|
||||
let settings = inspect_batch(
|
||||
InteractionRequest::from(form(dashboard::open_settings, &[]))
|
||||
.dispatch_async(registry(ctx.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(
|
||||
settings.updates_html_containing(dashboard::page_panel, "explicit app integrations")
|
||||
);
|
||||
assert!(settings.updates_text(dashboard::nav));
|
||||
assert!(settings.pushes_to("/settings"));
|
||||
|
||||
let heartbeat = inspect(live_status(ctx.projects().len()));
|
||||
assert!(heartbeat.updates_text(dashboard::live_status));
|
||||
assert!(heartbeat.payload_contains("heartbeat"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use futures_util::stream;
|
||||
use hemx::IntoEffect;
|
||||
use hemx_axum::{runtime_js, sse, EffectResponse, InteractionRequest};
|
||||
use hemx_saas_example::{home_page, live_status, registry, ui, AppContext};
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let app = app(AppContext::demo());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003")
|
||||
.await
|
||||
.expect("bind saas tutorial example");
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("serve saas tutorial example");
|
||||
}
|
||||
|
||||
fn app(ctx: AppContext) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(home).post(interact))
|
||||
.route("/settings", get(settings))
|
||||
.route("/events", get(events))
|
||||
.route("/hemx.js", get(runtime))
|
||||
.route("/app.css", get(css))
|
||||
.route("/metrics.js", get(metrics_js))
|
||||
.with_state(ctx)
|
||||
}
|
||||
|
||||
async fn home(State(ctx): State<AppContext>) -> impl IntoResponse {
|
||||
axum::response::Html(home_page(&ctx).into_string())
|
||||
}
|
||||
|
||||
async fn settings(State(ctx): State<AppContext>) -> impl IntoResponse {
|
||||
axum::response::Html(home_page(&ctx).into_string())
|
||||
}
|
||||
|
||||
async fn interact(
|
||||
State(ctx): State<AppContext>,
|
||||
request: InteractionRequest,
|
||||
) -> Result<EffectResponse, impl IntoResponse> {
|
||||
request.dispatch_async(registry(ctx)).await
|
||||
}
|
||||
|
||||
async fn events(
|
||||
Query(params): Query<BTreeMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> impl IntoResponse {
|
||||
let count = ctx.projects().len();
|
||||
if params.contains_key("once") {
|
||||
return sse(stream::iter([Ok::<_, Infallible>(
|
||||
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
|
||||
)]));
|
||||
}
|
||||
|
||||
sse(stream::iter([Ok::<_, Infallible>(
|
||||
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
|
||||
)]))
|
||||
}
|
||||
|
||||
async fn runtime() -> impl IntoResponse {
|
||||
runtime_js()
|
||||
}
|
||||
|
||||
async fn css() -> Response {
|
||||
Response::builder()
|
||||
.header("content-type", "text/css; charset=utf-8")
|
||||
.body(Body::from(include_str!("../templates/app.css")))
|
||||
.expect("css response")
|
||||
}
|
||||
|
||||
async fn metrics_js() -> Response {
|
||||
Response::builder()
|
||||
.header("content-type", "text/javascript; charset=utf-8")
|
||||
.body(Body::from(include_str!("../templates/metrics.js")))
|
||||
.expect("metrics js response")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
:root { color-scheme: light; font-family: Inter, system-ui, sans-serif; }
|
||||
body { margin: 0; background: #f7f4ee; color: #201b16; }
|
||||
.dashboard { max-width: 960px; margin: 0 auto; padding: 2rem; }
|
||||
.hero, .panel, .status-row { background: white; border: 1px solid #e6ded2; border-radius: 18px; padding: 1.25rem; box-shadow: 0 12px 40px rgba(34, 24, 8, 0.08); }
|
||||
.eyebrow { color: #8a5a00; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.lede { max-width: 56rem; color: #5d5147; }
|
||||
.tabs, .project-form, .status-row { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
.tabs { margin: 1rem 0; }
|
||||
button, input { font: inherit; }
|
||||
button { border: 0; border-radius: 999px; background: #1f5eff; color: white; padding: .65rem 1rem; }
|
||||
input { border: 1px solid #cfc4b8; border-radius: 10px; padding: .55rem .7rem; }
|
||||
.field-error, .flash { color: #a02b12; font-weight: 700; }
|
||||
.summary { color: #516034; }
|
||||
.project-list { display: grid; gap: .7rem; padding: 0; list-style: none; }
|
||||
.project-row { display: flex; justify-content: space-between; border: 1px solid #eee0cb; border-radius: 12px; padding: .75rem; }
|
||||
.metrics-island { min-width: 18rem; border-left: 4px solid #1f5eff; padding-left: 1rem; }
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{+ self.title +}</title>
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
<script src="/hemx.js" defer></script>
|
||||
<script src="/metrics.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{+= self.body =+}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<section class="dashboard" data-hemx-root="dashboard" data-hemx-sse="/events">
|
||||
<header class="hero">
|
||||
<p class="eyebrow">Production-shaped SaaS path</p>
|
||||
<h1>Projects</h1>
|
||||
<p class="lede">Auth-gated mutations, CSRF checks, local persistence, typed forms, generated swaps, page swaps, live status, plain CSS, and one explicit island.</p>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" data-hemx-slot="nav">
|
||||
<a href="/" data-hemx-nav="">Projects</a>
|
||||
<button type="button" data-hemx-handle="open_settings">Settings</button>
|
||||
</nav>
|
||||
|
||||
<section class="panel" data-hemx-slot="page_panel">
|
||||
<form class="project-form" data-hemx-handle="create_project" data-hemx-form="new_project" data-hemx-disable-while-pending>
|
||||
<input type="hidden" name="csrf" +value="self.csrf">
|
||||
<label>Project name
|
||||
<input name="name" required="required" maxlength="64" value="Launch checklist">
|
||||
</label>
|
||||
<button type="submit">Create project</button>
|
||||
<p class="field-error" data-hemx-error-for="name"></p>
|
||||
</form>
|
||||
|
||||
<p class="flash" data-hemx-slot="flash">{+ self.flash +}</p>
|
||||
<p class="summary" data-hemx-slot="summary">{+ self.summary +}</p>
|
||||
|
||||
<ul class="project-list" data-hemx-slot="project_row">
|
||||
<template h-for="row in &self.rows" h-key="row.id">
|
||||
{+ row +}
|
||||
</template>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="status-row">
|
||||
<p data-hemx-slot="live_status">Waiting for status…</p>
|
||||
<article class="metrics-island" data-hemx-island="metrics" +data-project-count="self.project_count">
|
||||
<h2>Metrics island</h2>
|
||||
<canvas width="320" height="140" aria-label="Project metrics chart"></canvas>
|
||||
<p data-island-readout="">Waiting for island script…</p>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,14 @@
|
||||
(() => {
|
||||
function render(island) {
|
||||
const count = island.getAttribute("data-project-count") || "0";
|
||||
const readout = island.querySelector("[data-island-readout]");
|
||||
if (readout) readout.textContent = `${count} persisted project${count === "1" ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function boot() {
|
||||
for (const island of document.querySelectorAll('[data-hemx-island="metrics"]')) render(island);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
document.addEventListener("hemx:after-settle", boot);
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
<li class="project-row" +data-key="self.id">
|
||||
<strong>{+ self.name +}</strong>
|
||||
<span>{+ self.owner +}</span>
|
||||
</li>
|
||||
@@ -0,0 +1,4 @@
|
||||
<section class="settings-page">
|
||||
<h2>Settings</h2>
|
||||
<p>{+ self.message +}</p>
|
||||
</section>
|
||||
@@ -301,6 +301,8 @@ fn allowed_example_script(path: &Path, line: &str) -> bool {
|
||||
line.contains(r#"<script src="/hemx.js" defer></script>"#)
|
||||
|| (path.ends_with("examples/techdemo/templates/app_shell.heml")
|
||||
&& line.contains(r#"<script src="/island.js" defer></script>"#))
|
||||
|| (path.ends_with("examples/saas/templates/app_shell.heml")
|
||||
&& line.contains(r#"<script src="/metrics.js" defer></script>"#))
|
||||
}
|
||||
|
||||
fn contains_inline_event_handler(line: &str) -> bool {
|
||||
|
||||
Reference in New Issue
Block a user