serve: add backlog selection to web UI
- Add /b/{name} route for viewing a specific backlog
- Root / redirects to default backlog or first available
- Header shows an inline popover selector when multiple backlogs exist
- --backlog flag is now optional on the serve command
change twkznxzkvywmmzkqmwksqllzqlrxsunw
commit c7510f3105d18fa1ea0a87601e4b98e375d91099
author Alpha Chen <alpha@kejadlen.dev>
date
parent lkvxuxms
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 3ebd03f..b719374 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -1,6 +1,6 @@
-use axum::extract::State;
+use axum::extract::{Path, State};
 use axum::http::header;
-use axum::response::IntoResponse;
+use axum::response::{IntoResponse, Redirect};
 use axum::{Router, routing::get};
 use maud::{DOCTYPE, Markup, PreEscaped, html};
 use ranger::key;
@@ -17,17 +17,22 @@ const STYLE_CSS: &str = include_str!("../../../../static/style.css");
 #[derive(Clone)]
 struct AppState {
     pool: SqlitePool,
-    backlog_name: String,
+    default_backlog: Option<String>,
 }
 
-pub async fn run(pool: &SqlitePool, port: u16, backlog_name: String) -> color_eyre::Result<()> {
+pub async fn run(
+    pool: &SqlitePool,
+    port: u16,
+    default_backlog: Option<String>,
+) -> color_eyre::Result<()> {
     let state = AppState {
         pool: pool.clone(),
-        backlog_name,
+        default_backlog,
     };
 
     let app = Router::new()
         .route("/", get(index))
+        .route("/b/{name}", get(board))
         .route("/static/style.css", get(serve_css))
         .with_state(state);
 
@@ -44,17 +49,38 @@ async fn serve_css() -> impl IntoResponse {
     ([(header::CONTENT_TYPE, "text/css")], STYLE_CSS)
 }
 
-async fn index(State(state): State<AppState>) -> Markup {
-    match render_board(&state).await {
+async fn index(State(state): State<AppState>) -> Result<Redirect, Markup> {
+    // If a default backlog is set, redirect to it
+    if let Some(ref name) = state.default_backlog {
+        return Ok(Redirect::to(&format!("/b/{name}")));
+    }
+
+    // Otherwise, redirect to the first backlog
+    let mut conn = state.pool.acquire().await.map_err(error_page)?;
+    let backlogs = ops::backlog::list(&mut conn).await.map_err(error_page)?;
+
+    match backlogs.first() {
+        Some(b) => Ok(Redirect::to(&format!("/b/{}", b.name))),
+        None => Err(error_page("No backlogs found")),
+    }
+}
+
+async fn board(State(state): State<AppState>, Path(name): Path<String>) -> Markup {
+    match render_board(&state, &name).await {
         Ok(markup) => markup,
-        Err(e) => html! {
-            html {
-                body {
-                    h1 { "Error" }
-                    pre { (e) }
-                }
+        Err(e) => error_page(e),
+    }
+}
+
+fn error_page(e: impl std::fmt::Display) -> Markup {
+    html! {
+        (DOCTYPE)
+        html {
+            body {
+                h1 { "Error" }
+                pre { (e.to_string()) }
             }
-        },
+        }
     }
 }
 
@@ -69,9 +95,14 @@ struct TaskView {
     done_subtask_count: usize,
 }
 
-async fn render_board(state: &AppState) -> color_eyre::Result<Markup> {
+async fn render_board(state: &AppState, backlog_name: &str) -> color_eyre::Result<Markup> {
     let mut conn = state.pool.acquire().await?;
-    let backlog = ops::backlog::get_by_name(&mut conn, &state.backlog_name).await?;
+
+    // Fetch all backlogs for the selector
+    let backlogs = ops::backlog::list(&mut conn).await?;
+    let backlog_names: Vec<String> = backlogs.iter().map(|b| b.name.clone()).collect();
+
+    let backlog = ops::backlog::get_by_name(&mut conn, backlog_name).await?;
     let all_keys = ops::task::keys_for_backlog(&mut conn, backlog.id).await?;
     let prefixes = key::unique_prefix_lengths(&all_keys);
 
@@ -102,7 +133,6 @@ async fn render_board(state: &AppState) -> color_eyre::Result<Markup> {
 
     let total = in_progress.len() + queued.len() + icebox.len() + done.len();
     let active = in_progress.len() + queued.len();
-    let backlog_name = &state.backlog_name;
 
     Ok(html! {
         (DOCTYPE)
@@ -115,7 +145,18 @@ async fn render_board(state: &AppState) -> color_eyre::Result<Markup> {
             }
             body {
                 header {
-                    h1 { "ranger" span.sep { "›" } (backlog_name) }
+                    h1 {
+                        "ranger" span.sep { "›" }
+                        @if backlog_names.len() > 1 {
+                            select.backlog-select onchange="window.location.href='/b/'+this.value" {
+                                @for name in &backlog_names {
+                                    option value=(name) selected[name == backlog_name] { (name) }
+                                }
+                            }
+                        } @else {
+                            (backlog_name)
+                        }
+                    }
                     div.counts { (active) " active · " (total) " total" }
                 }
                 div.board {
diff --git a/src/bin/ranger/main.rs b/src/bin/ranger/main.rs
index f199958..8dacfb8 100644
--- a/src/bin/ranger/main.rs
+++ b/src/bin/ranger/main.rs
@@ -53,9 +53,9 @@ enum Commands {
         /// Port to listen on
         #[arg(long, default_value_t = 3000)]
         port: u16,
-        /// Backlog to display
+        /// Default backlog to display
         #[arg(long, env = "RANGER_DEFAULT_BACKLOG", add = ArgValueCompleter::new(completions::complete_backlog_names))]
-        backlog: String,
+        backlog: Option<String>,
     },
 }
 
diff --git a/static/style.css b/static/style.css
index 274c0ea..dac912e 100644
--- a/static/style.css
+++ b/static/style.css
@@ -93,6 +93,29 @@ header h1 .sep {
   margin: 0 6px;
 }
 
+/* === Backlog selector === */
+.backlog-select {
+  font-family: var(--font-sans);
+  font-size: var(--step-0);
+  font-weight: 600;
+  color: var(--color-text-header);
+  background: rgba(255, 255, 255, 0.1);
+  border: 1px solid rgba(255, 255, 255, 0.2);
+  border-radius: 4px;
+  padding: 2px 6px;
+  cursor: pointer;
+  outline: none;
+}
+
+.backlog-select:hover {
+  background: rgba(255, 255, 255, 0.15);
+}
+
+.backlog-select option {
+  color: #333;
+  background: #fff;
+}
+
 header .counts {
   font-size: var(--step-mono);
   color: rgba(224, 224, 224, 0.5);