add drag-and-drop reordering and state changes in web UI
change knmsyyoqvqswqosttmrmzvoywlyompmy
commit 34690743af182b938438c41bf03cb5b495c18588
author Alpha Chen <alpha@kejadlen.dev>
date
parent uqytulys
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 44d4c12..0edce19 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -1,12 +1,14 @@
 use axum::extract::{Path, State};
+use axum::http::StatusCode;
 use axum::http::header;
 use axum::response::{IntoResponse, Redirect};
-use axum::{Router, routing::get};
+use axum::{Json, Router, routing::get, routing::post};
 use maud::{DOCTYPE, Markup, PreEscaped, html};
 use ranger::key;
 use ranger::models::Task;
 use ranger::ops;
 use ranger::ops::task::ListFilter;
+use serde::Deserialize;
 use sqlx::SqlitePool;
 use std::net::SocketAddr;
 use tokio::net::TcpListener;
@@ -34,6 +36,7 @@ pub async fn run(
         .route("/", get(index))
         .route("/b/{name}", get(board))
         .route("/static/style.css", get(serve_css))
+        .route("/api/tasks/{key}/move", post(api_move_task))
         .with_state(state);
 
     let addr = SocketAddr::from(([0, 0, 0, 0], port));
@@ -72,6 +75,129 @@ async fn board(State(state): State<AppState>, Path(name): Path<String>) -> Marku
     }
 }
 
+#[derive(Deserialize)]
+struct MoveRequest {
+    state: Option<String>,
+    before: Option<String>,
+    after: Option<String>,
+}
+
+async fn api_move_task(
+    State(state): State<AppState>,
+    Path(key): Path<String>,
+    Json(body): Json<MoveRequest>,
+) -> Result<StatusCode, (StatusCode, String)> {
+    let mut conn = state
+        .pool
+        .acquire()
+        .await
+        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+    let task = ops::task::get_by_key_prefix(&mut conn, &key, None)
+        .await
+        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+
+    // Apply state change if requested
+    if let Some(ref state_str) = body.state {
+        let new_state: ranger::models::State =
+            state_str
+                .parse()
+                .map_err(|e: ranger::models::InvalidStateError| {
+                    (StatusCode::BAD_REQUEST, e.to_string())
+                })?;
+        let updated = ops::task::edit(&mut conn, task.id, None, None, Some(new_state))
+            .await
+            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+
+        // Position within the new state group
+        match (&body.before, &body.after) {
+            (Some(b), Some(a)) => {
+                let before = ops::task::get_by_key_prefix(&mut conn, b, None)
+                    .await
+                    .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+                let after = ops::task::get_by_key_prefix(&mut conn, a, None)
+                    .await
+                    .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+                ops::task::move_task(
+                    &mut conn,
+                    &updated,
+                    ops::task::Placement::Between {
+                        after: &after,
+                        before: &before,
+                    },
+                )
+                .await
+                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+            }
+            (Some(b), None) => {
+                let before = ops::task::get_by_key_prefix(&mut conn, b, None)
+                    .await
+                    .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+                ops::task::move_task(&mut conn, &updated, ops::task::Placement::Before(&before))
+                    .await
+                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+            }
+            (None, Some(a)) => {
+                let after = ops::task::get_by_key_prefix(&mut conn, a, None)
+                    .await
+                    .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+                ops::task::move_task(&mut conn, &updated, ops::task::Placement::After(&after))
+                    .await
+                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+            }
+            (None, None) => {
+                // No position specified — state change auto-positions
+            }
+        }
+    } else {
+        // No state change — just reorder within same state
+        match (&body.before, &body.after) {
+            (Some(b), Some(a)) => {
+                let before = ops::task::get_by_key_prefix(&mut conn, b, None)
+                    .await
+                    .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+                let after = ops::task::get_by_key_prefix(&mut conn, a, None)
+                    .await
+                    .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+                ops::task::move_task(
+                    &mut conn,
+                    &task,
+                    ops::task::Placement::Between {
+                        after: &after,
+                        before: &before,
+                    },
+                )
+                .await
+                .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+            }
+            (Some(b), None) => {
+                let before = ops::task::get_by_key_prefix(&mut conn, b, None)
+                    .await
+                    .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+                ops::task::move_task(&mut conn, &task, ops::task::Placement::Before(&before))
+                    .await
+                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+            }
+            (None, Some(a)) => {
+                let after = ops::task::get_by_key_prefix(&mut conn, a, None)
+                    .await
+                    .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
+                ops::task::move_task(&mut conn, &task, ops::task::Placement::After(&after))
+                    .await
+                    .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+            }
+            (None, None) => {
+                return Err((
+                    StatusCode::BAD_REQUEST,
+                    "before or after is required when not changing state".into(),
+                ));
+            }
+        }
+    }
+
+    Ok(StatusCode::NO_CONTENT)
+}
+
 fn error_page(e: impl std::fmt::Display) -> Markup {
     html! {
         (DOCTYPE)
@@ -85,6 +211,7 @@ fn error_page(e: impl std::fmt::Display) -> Markup {
 }
 
 struct TaskView {
+    key: String,
     key_prefix: String,
     key_rest: String,
     title: String,
@@ -174,8 +301,8 @@ async fn render_board(
                 }
                 div.board {
                     (render_backlog_panel(&in_progress, &ready))
-                    (render_column_panel("Icebox", "state-icebox", &icebox))
-                    (render_column_panel("Done", "state-done", &done))
+                    (render_column_panel("Icebox", "state-icebox", "icebox", &icebox))
+                    (render_column_panel("Done", "state-done", "done", &done))
                 }
                 (keyboard_nav_script())
             }
@@ -191,29 +318,26 @@ fn render_backlog_panel(in_progress: &[TaskView], ready: &[TaskView]) -> Markup
                 h2 { "Backlog" }
                 span.count { (count) }
             }
-            @if in_progress.is_empty() && ready.is_empty() {
-                div.empty { "No active tasks" }
-            } @else {
-                @if !in_progress.is_empty() {
-                    div.state-in-progress {
-                        @for task in in_progress {
-                            (render_task(task))
-                        }
-                    }
+            div.state-in-progress.drop-zone data-state="in_progress" {
+                @for task in in_progress {
+                    (render_task(task))
                 }
-                @if !ready.is_empty() {
-                    div.state-ready {
-                        @for task in ready {
-                            (render_task(task))
-                        }
-                    }
+            }
+            div.state-ready.drop-zone data-state="ready" {
+                @for task in ready {
+                    (render_task(task))
                 }
             }
         }
     }
 }
 
-fn render_column_panel(label: &str, state_class: &str, tasks: &[TaskView]) -> Markup {
+fn render_column_panel(
+    label: &str,
+    state_class: &str,
+    state_value: &str,
+    tasks: &[TaskView],
+) -> Markup {
     let count = tasks.len();
     html! {
         div.panel {
@@ -221,10 +345,10 @@ fn render_column_panel(label: &str, state_class: &str, tasks: &[TaskView]) -> Ma
                 h2 { (label) }
                 span.count { (count) }
             }
-            @if tasks.is_empty() {
-                div.empty { "No " (label.to_lowercase()) " tasks" }
-            } @else {
-                div class=(state_class) {
+            div class=(format!("{state_class} drop-zone")) data-state=(state_value) {
+                @if tasks.is_empty() {
+                    div.empty { "No " (label.to_lowercase()) " tasks" }
+                } @else {
                     @for task in tasks {
                         (render_task(task))
                     }
@@ -238,7 +362,7 @@ fn render_task(task: &TaskView) -> Markup {
     let has_details = task.description.is_some();
     html! {
         @if has_details {
-            details.task {
+            details.task draggable="true" data-key=(task.key) {
                 summary.task-header tabindex="0" {
                     span.key {
                         span.key-prefix { (task.key_prefix) }
@@ -261,7 +385,7 @@ fn render_task(task: &TaskView) -> Markup {
                 }
             }
         } @else {
-            div.task tabindex="0" {
+            div.task draggable="true" data-key=(task.key) tabindex="0" {
                 div.task-header {
                     span.key {
                         span.key-prefix { (task.key_prefix) }
@@ -286,16 +410,18 @@ fn keyboard_nav_script() -> Markup {
         script {
             (PreEscaped(r#"
             (function() {
-                // Close backlog popover on outside click
+                // === Backlog popover ===
                 document.addEventListener('click', function(e) {
                     var dialog = document.getElementById('backlog-dialog');
                     if (dialog && dialog.open && !dialog.contains(e.target) && !e.target.closest('.backlog-trigger')) {
                         dialog.close();
                     }
                 });
+
+                // === Keyboard navigation ===
                 function getFocusables() {
                     return Array.from(document.querySelectorAll(
-                        'details.task > summary, div.task'
+                        'details.task > summary, div.task[data-key]'
                     ));
                 }
                 function focusEl(els, idx) {
@@ -319,6 +445,144 @@ fn keyboard_nav_script() -> Markup {
                         document.activeElement.click();
                     }
                 });
+
+                // === Drag and drop ===
+                var draggedKey = null;
+                var draggedEl = null;
+
+                function getTaskEl(el) {
+                    return el.closest('[data-key]');
+                }
+
+                function getDropZone(el) {
+                    return el.closest('.drop-zone');
+                }
+
+                document.addEventListener('dragstart', function(e) {
+                    var task = getTaskEl(e.target);
+                    if (!task) return;
+                    draggedKey = task.dataset.key;
+                    draggedEl = task;
+                    task.classList.add('dragging');
+                    e.dataTransfer.effectAllowed = 'move';
+                    e.dataTransfer.setData('text/plain', draggedKey);
+                });
+
+                document.addEventListener('dragend', function(e) {
+                    if (draggedEl) draggedEl.classList.remove('dragging');
+                    document.querySelectorAll('.drop-indicator').forEach(function(el) { el.remove(); });
+                    document.querySelectorAll('.drop-zone-active').forEach(function(el) { el.classList.remove('drop-zone-active'); });
+                    draggedKey = null;
+                    draggedEl = null;
+                });
+
+                document.addEventListener('dragover', function(e) {
+                    var zone = getDropZone(e.target);
+                    if (!zone) return;
+                    e.preventDefault();
+                    e.dataTransfer.dropEffect = 'move';
+
+                    // Clear previous indicators
+                    document.querySelectorAll('.drop-indicator').forEach(function(el) { el.remove(); });
+                    document.querySelectorAll('.drop-zone-active').forEach(function(el) { el.classList.remove('drop-zone-active'); });
+
+                    var tasks = Array.from(zone.querySelectorAll('[data-key]'));
+                    if (tasks.length === 0) {
+                        zone.classList.add('drop-zone-active');
+                        return;
+                    }
+
+                    // Find insertion point
+                    var closestTask = null;
+                    var insertBefore = true;
+                    var minDist = Infinity;
+                    for (var i = 0; i < tasks.length; i++) {
+                        var rect = tasks[i].getBoundingClientRect();
+                        var midY = rect.top + rect.height / 2;
+                        var dist = Math.abs(e.clientY - midY);
+                        if (dist < minDist) {
+                            minDist = dist;
+                            closestTask = tasks[i];
+                            insertBefore = e.clientY < midY;
+                        }
+                    }
+
+                    if (closestTask) {
+                        var indicator = document.createElement('div');
+                        indicator.className = 'drop-indicator';
+                        if (insertBefore) {
+                            closestTask.parentNode.insertBefore(indicator, closestTask);
+                        } else {
+                            closestTask.parentNode.insertBefore(indicator, closestTask.nextSibling);
+                        }
+                    }
+                });
+
+                document.addEventListener('drop', function(e) {
+                    e.preventDefault();
+                    var zone = getDropZone(e.target);
+                    if (!zone || !draggedKey) return;
+
+                    var targetState = zone.dataset.state;
+                    var tasks = Array.from(zone.querySelectorAll('[data-key]'))
+                        .filter(function(t) { return t.dataset.key !== draggedKey; });
+
+                    // Find drop position
+                    var beforeKey = null;
+                    var afterKey = null;
+
+                    if (tasks.length === 0) {
+                        // Empty zone — just change state
+                    } else {
+                        var closestTask = null;
+                        var insertBefore = true;
+                        var minDist = Infinity;
+                        for (var i = 0; i < tasks.length; i++) {
+                            var rect = tasks[i].getBoundingClientRect();
+                            var midY = rect.top + rect.height / 2;
+                            var dist = Math.abs(e.clientY - midY);
+                            if (dist < minDist) {
+                                minDist = dist;
+                                closestTask = tasks[i];
+                                insertBefore = e.clientY < midY;
+                            }
+                        }
+
+                        if (closestTask) {
+                            var idx = tasks.indexOf(closestTask);
+                            if (insertBefore) {
+                                beforeKey = closestTask.dataset.key;
+                                if (idx > 0) afterKey = tasks[idx - 1].dataset.key;
+                            } else {
+                                afterKey = closestTask.dataset.key;
+                                if (idx < tasks.length - 1) beforeKey = tasks[idx + 1].dataset.key;
+                            }
+                        }
+                    }
+
+                    var body = {};
+                    // Determine current state of dragged element
+                    var draggedZone = draggedEl ? getDropZone(draggedEl) : null;
+                    var currentState = draggedZone ? draggedZone.dataset.state : null;
+
+                    if (targetState !== currentState) {
+                        body.state = targetState;
+                    }
+                    if (beforeKey) body.before = beforeKey;
+                    if (afterKey) body.after = afterKey;
+
+                    fetch('/api/tasks/' + encodeURIComponent(draggedKey) + '/move', {
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                        body: JSON.stringify(body)
+                    }).then(function(res) {
+                        if (res.ok) {
+                            window.location.reload();
+                        } else {
+                            res.text().then(function(t) { console.error('Move failed:', t); });
+                        }
+                    });
+                });
             })();
             "#))
         }
@@ -345,6 +609,7 @@ async fn to_task_views(
             .collect();
 
         views.push(TaskView {
+            key: task.key.clone(),
             key_prefix,
             key_rest,
             title: task.title.clone(),
diff --git a/static/style.css b/static/style.css
index 72c57b7..d30c1b1 100644
--- a/static/style.css
+++ b/static/style.css
@@ -409,6 +409,39 @@ details.task[open] > summary .expand-icon {
 }
 
 
+/* === Drag and drop === */
+.dragging {
+  opacity: 0.4;
+}
+
+.drop-indicator {
+  height: 2px;
+  background: var(--color-accent);
+  border-radius: 1px;
+  margin: -1px 0;
+  position: relative;
+  z-index: 10;
+}
+
+.drop-zone {
+  min-height: 40px;
+  transition: background 0.15s ease;
+}
+
+.drop-zone-active {
+  background: rgba(106, 159, 216, 0.1);
+  border-radius: var(--radius);
+}
+
+[draggable="true"] {
+  cursor: grab;
+}
+
+[draggable="true"]:active {
+  cursor: grabbing;
+}
+
+
 /* === Responsive layout === */
 @media (max-width: 960px) {
   .board {