use std::convert::Infallible;
use std::sync::Arc;
use acton_service::prelude::*;
use acton_service::session::{
create_memory_session_layer, AuthSession, FlashMessage, SessionConfig, TypedSession,
};
use acton_service::versioning::VersionedApiBuilder;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: u64,
pub title: String,
pub completed: bool,
}
#[derive(Debug, Default)]
pub struct TaskStore {
tasks: Vec<Task>,
next_id: u64,
}
impl TaskStore {
fn add(&mut self, title: String) -> Task {
self.next_id += 1;
let task = Task {
id: self.next_id,
title,
completed: false,
};
self.tasks.push(task.clone());
task
}
fn get(&self, id: u64) -> Option<&Task> {
self.tasks.iter().find(|t| t.id == id)
}
fn update(&mut self, id: u64, title: String) -> Option<Task> {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
task.title = title;
Some(task.clone())
} else {
None
}
}
fn toggle(&mut self, id: u64) -> Option<Task> {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
task.completed = !task.completed;
Some(task.clone())
} else {
None
}
}
fn delete(&mut self, id: u64) -> bool {
let len_before = self.tasks.len();
self.tasks.retain(|t| t.id != id);
self.tasks.len() < len_before
}
fn all(&self) -> Vec<Task> {
self.tasks.clone()
}
fn stats(&self) -> (usize, usize, usize) {
let total = self.tasks.len();
let completed = self.tasks.iter().filter(|t| t.completed).count();
let pending = total - completed;
(total, completed, pending)
}
}
type SharedStore = Arc<RwLock<TaskStore>>;
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
ctx: TemplateContext,
tasks: Vec<Task>,
total_tasks: usize,
completed_tasks: usize,
pending_tasks: usize,
}
#[derive(Template)]
#[template(path = "tasks/item.html")]
struct TaskItemTemplate {
task: Task,
}
#[derive(Template)]
#[template(path = "tasks/edit.html")]
struct TaskEditTemplate {
task: Task,
}
#[derive(Template)]
#[template(path = "auth/login.html")]
struct LoginTemplate {
ctx: TemplateContext,
}
#[derive(Debug, Deserialize)]
struct CreateTaskForm {
title: String,
}
#[derive(Debug, Deserialize)]
struct UpdateTaskForm {
title: String,
}
#[derive(Debug, Deserialize)]
struct LoginForm {
username: String,
}
fn render_stats_oob(total: usize, completed: usize, pending: usize) -> String {
format!(
r#"<span class="stat-value" id="total-count" hx-swap-oob="outerHTML">{}</span>
<span class="stat-value" id="pending-count" hx-swap-oob="outerHTML">{}</span>
<span class="stat-value" id="completed-count" hx-swap-oob="outerHTML">{}</span>"#,
total, pending, completed
)
}
async fn index(
flash: FlashMessages,
auth: TypedSession<AuthSession>,
Extension(store): Extension<SharedStore>,
) -> impl IntoResponse {
let tasks = store.read().await.all();
let (total, completed, pending) = store.read().await.stats();
let ctx = TemplateContext::new()
.with_path("/")
.with_auth(auth.data().user_id.clone())
.with_flash(flash.into_messages());
HtmlTemplate::page(IndexTemplate {
ctx,
tasks,
total_tasks: total,
completed_tasks: completed,
pending_tasks: pending,
})
}
async fn create_task(
Extension(store): Extension<SharedStore>,
Form(form): Form<CreateTaskForm>,
) -> impl IntoResponse {
let title = form.title.trim();
if title.is_empty() {
return Html("<div class=\"flash flash-error\">Task title cannot be empty</div>")
.into_response();
}
let task = store.write().await.add(title.to_string());
let (total, completed, pending) = store.read().await.stats();
let task_html = TaskItemTemplate { task }.render().unwrap_or_default();
let stats_html = render_stats_oob(total, completed, pending);
let delete_empty = r#"<li id="empty-message" hx-swap-oob="delete"></li>"#;
Html(format!("{}{}{}", task_html, stats_html, delete_empty)).into_response()
}
async fn get_task(
Path(id): Path<u64>,
Extension(store): Extension<SharedStore>,
) -> impl IntoResponse {
match store.read().await.get(id).cloned() {
Some(task) => HtmlTemplate::fragment(TaskItemTemplate { task }).into_response(),
None => (StatusCode::NOT_FOUND, "Task not found").into_response(),
}
}
async fn edit_task_form(
Path(id): Path<u64>,
Extension(store): Extension<SharedStore>,
) -> impl IntoResponse {
match store.read().await.get(id).cloned() {
Some(task) => HtmlTemplate::fragment(TaskEditTemplate { task }).into_response(),
None => (StatusCode::NOT_FOUND, "Task not found").into_response(),
}
}
async fn update_task(
Path(id): Path<u64>,
Extension(store): Extension<SharedStore>,
Form(form): Form<UpdateTaskForm>,
) -> impl IntoResponse {
let title = form.title.trim();
if title.is_empty() {
if let Some(task) = store.read().await.get(id).cloned() {
return HtmlTemplate::fragment(TaskItemTemplate { task }).into_response();
}
return (StatusCode::NOT_FOUND, "Task not found").into_response();
}
match store.write().await.update(id, title.to_string()) {
Some(task) => HtmlTemplate::fragment(TaskItemTemplate { task }).into_response(),
None => (StatusCode::NOT_FOUND, "Task not found").into_response(),
}
}
async fn toggle_task(
Path(id): Path<u64>,
Extension(store): Extension<SharedStore>,
) -> impl IntoResponse {
let toggle_result = { store.write().await.toggle(id) };
match toggle_result {
Some(task) => {
let (total, completed, pending) = store.read().await.stats();
let task_html = TaskItemTemplate { task }.render().unwrap_or_default();
let stats_html = render_stats_oob(total, completed, pending);
Html(format!("{}{}", task_html, stats_html)).into_response()
}
None => (StatusCode::NOT_FOUND, "Task not found").into_response(),
}
}
async fn delete_task(
Path(id): Path<u64>,
Extension(store): Extension<SharedStore>,
) -> impl IntoResponse {
let deleted = { store.write().await.delete(id) };
if deleted {
let (total, completed, pending) = store.read().await.stats();
let stats_html = render_stats_oob(total, completed, pending);
Html(stats_html).into_response()
} else {
(StatusCode::NOT_FOUND, "Task not found").into_response()
}
}
async fn events(
Extension(broadcaster): Extension<Arc<SseBroadcaster>>,
) -> Sse<impl Stream<Item = std::result::Result<SseEvent, Infallible>>> {
let rx = broadcaster.subscribe();
let stream = stream::unfold(rx, |mut rx| async move {
match rx.recv().await {
Ok(msg) => {
let mut event = SseEvent::default().data(msg.data);
if let Some(event_type) = msg.event_type {
event = event.event(event_type);
}
Some((Ok(event), rx))
}
Err(_) => None, }
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
async fn login_page(flash: FlashMessages, auth: TypedSession<AuthSession>) -> impl IntoResponse {
let ctx = TemplateContext::new()
.with_path("/login")
.with_auth(auth.data().user_id.clone())
.with_flash(flash.into_messages());
HtmlTemplate::page(LoginTemplate { ctx })
}
async fn login(
mut auth: TypedSession<AuthSession>,
Form(form): Form<LoginForm>,
) -> impl IntoResponse {
let username = form.username.trim();
if username.is_empty() {
let _ =
FlashMessages::push(auth.session(), FlashMessage::error("Username is required")).await;
return Redirect::to("/login").into_response();
}
auth.data_mut()
.login(username.to_string(), vec!["user".to_string()]);
if let Err(e) = auth.save().await {
tracing::error!("Failed to save session: {}", e);
}
let _ = FlashMessages::push(
auth.session(),
FlashMessage::success(format!("Welcome back, {}!", username)),
)
.await;
Redirect::to("/").into_response()
}
async fn logout(mut auth: TypedSession<AuthSession>) -> impl IntoResponse {
let _ = FlashMessages::push(
auth.session(),
FlashMessage::info("You have been logged out"),
)
.await;
auth.data_mut().logout();
if let Err(e) = auth.save().await {
tracing::error!("Failed to save session: {}", e);
}
Redirect::to("/").into_response()
}
#[tokio::main]
async fn main() -> Result<()> {
let store: SharedStore = Arc::new(RwLock::new(TaskStore::default()));
let broadcaster = Arc::new(SseBroadcaster::new());
{
let mut s = store.write().await;
s.add("Learn HTMX with acton-service".to_string());
s.add("Build something awesome".to_string());
s.add("Deploy to production".to_string());
}
let session_config = SessionConfig::default();
let session_layer = create_memory_session_layer(&session_config);
let routes = VersionedApiBuilder::new()
.with_frontend_routes(|router| {
router
.route("/", get(index))
.route("/login", get(login_page))
.route("/tasks", post(create_task))
.route(
"/tasks/{id}",
get(get_task).put(update_task).delete(delete_task),
)
.route("/tasks/{id}/edit", get(edit_task_form))
.route("/tasks/{id}/toggle", post(toggle_task))
.route("/events", get(events))
.route("/login", post(login))
.route("/logout", post(logout))
.layer(Extension(store))
.layer(Extension(broadcaster))
.layer(session_layer)
})
.build_routes();
ServiceBuilder::new()
.with_routes(routes)
.build()
.serve()
.await?;
Ok(())
}