From 2f88728b15fcb6ff4cfc0d56945a2e91f7cfb9b2 Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 16 Mar 2026 16:24:14 -0600 Subject: [PATCH] Add read-only web UI subcommand (td webui) --- Cargo.lock | 293 +++++++++++++++++++- Cargo.toml | 2 + src/cli.rs | 12 + src/cmd/mod.rs | 5 + src/cmd/webui.rs | 599 +++++++++++++++++++++++++++++++++++++++++ static/oat.min.css | 1 + static/oat.min.js | 1 + static/td.css | 51 ++++ templates/base.html | 38 +++ templates/error.html | 10 + templates/index.html | 43 +++ templates/project.html | 163 +++++++++++ templates/task.html | 122 +++++++++ 13 files changed, 1338 insertions(+), 2 deletions(-) create mode 100644 src/cmd/webui.rs create mode 100644 static/oat.min.css create mode 100644 static/oat.min.js create mode 100644 static/td.css create mode 100644 templates/base.html create mode 100644 templates/error.html create mode 100644 templates/index.html create mode 100644 templates/project.html create mode 100644 templates/task.html diff --git a/Cargo.lock b/Cargo.lock index e773bb2a5fb41b0cc4f40399c00d273f791dcf0b..bdd3acea2f2285b10a8ef56c42ec790ca6485d07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,58 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ccd462b64c3c72f1be8305905a85d85403d768e8690c9b8bd3b9009a5761679" +[[package]] +name = "askama" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08e1676b346cadfec169374f949d7490fd80a24193d37d2afce0c047cf695e57" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7661ff56517787343f376f75db037426facd7c8d3049cef8911f1e75016f3a37" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.117", +] + +[[package]] +name = "askama_macros" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713ee4dbfd1eb719c2dab859465b01fa1d21cb566684614a713a6b7a99a4e47b" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d62d674238a526418b30c0def480d5beadb9d8964e7f38d635b03bf639c704c" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + [[package]] name = "assert_cmd" version = "2.1.2" @@ -378,12 +430,73 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -1345,12 +1458,77 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1824,7 +2002,7 @@ dependencies = [ "serde_json", "sha-1", "sha2", - "socket2", + "socket2 0.5.10", "spake2", "stun_codec", "tar", @@ -1844,6 +2022,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md5" version = "0.7.0" @@ -1856,6 +2040,23 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "noise-protocol" version = "0.2.0" @@ -2443,6 +2644,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "salsa20" version = "0.10.2" @@ -2544,6 +2751,29 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -2643,6 +2873,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spake2" version = "0.4.0" @@ -2719,6 +2959,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -2856,8 +3102,12 @@ version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ + "libc", + "mio", "pin-project-lite", + "socket2 0.6.3", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -2871,6 +3121,34 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3364,6 +3642,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3512,10 +3799,12 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yatd" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", + "askama", "assert_cmd", + "axum", "chrono", "clap", "comfy-table", diff --git a/Cargo.toml b/Cargo.toml index 2721b4313bc24ea84a228e6e58df941ab74a8e3b..fe90d20c74eb7875ccb744532d8147ec4c296fdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ ulid = "1" magic-wormhole = "0.7.6" tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros"] } fs2 = "0.4" +axum = "0.8" +askama = "0.15" [dev-dependencies] assert_cmd = "2" diff --git a/src/cli.rs b/src/cli.rs index 512cdfeae0fb213c0e02217d309a422b2a6a4ae6..fd0ec7a91b3ca1e8a6b65a3420d49efec6dbcc53 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -208,6 +208,18 @@ pub enum Command { code: Option, }, + /// Launch a read-only web UI + #[command(name = "webui")] + WebUi { + /// Listen address + #[arg(long, default_value = "127.0.0.1")] + host: String, + + /// Listen port + #[arg(long, default_value_t = 8080)] + port: u16, + }, + /// Install the agent skill file (SKILL.md) Skill { /// Skills directory (writes managing-tasks-with-td/SKILL.md inside) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index c1579875f635c75ebcad2ebf0d7079b9548eaf5f..67c8bd9e858c7b026f5d8d9b50d2135aba89a12b 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -19,6 +19,7 @@ mod stats; pub mod sync; mod tidy; mod update; +mod webui; use crate::cli::{Cli, Command}; use crate::db; @@ -173,6 +174,10 @@ pub fn dispatch(cli: &Cli) -> Result<()> { let root = require_root()?; sync::run(&root, code.as_deref(), cli.json) } + Command::WebUi { host, port } => { + let root = require_root()?; + webui::run(&root, host, *port, cli.project.as_deref()) + } Command::Skill { dir } => skill::run(dir.as_deref()), } } diff --git a/src/cmd/webui.rs b/src/cmd/webui.rs new file mode 100644 index 0000000000000000000000000000000000000000..2c5e3598dc1c6be1a5c5ebd7f972ba045fd6c3bf --- /dev/null +++ b/src/cmd/webui.rs @@ -0,0 +1,599 @@ +use std::collections::HashSet; +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use askama::Template; +use axum::extract::{Path as AxumPath, Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::get; +use axum::Router; + +use crate::db::{self, Store, TaskId}; +use crate::score; + +const PAGE_SIZE: usize = 25; + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct AppState { + data_root: Arc, +} + +// --------------------------------------------------------------------------- +// Template view-models +// --------------------------------------------------------------------------- + +/// A project card on the root page — either healthy or failed. +enum ProjectCard { + Ok { + name: String, + open: usize, + in_progress: usize, + closed: usize, + total: usize, + }, + Err { + name: String, + error: String, + }, +} + +/// Minimal view-model for a scored task in the "Next Up" table. +struct ScoredEntry { + id: String, + short_id: String, + title: String, + score: String, +} + +/// Minimal view-model for a task row in the project task table. +struct TaskRow { + full_id: String, + short_id: String, + status: String, + priority: String, + effort: String, + title: String, +} + +/// View-model for the task detail page. +struct TaskView { + full_id: String, + short_id: String, + title: String, + description: String, + task_type: String, + status: String, + priority: String, + effort: String, + created_at: String, + updated_at: String, + labels: Vec, + logs: Vec, +} + +struct LogView { + timestamp: String, + message: String, +} + +/// A blocker reference for the task detail page. +struct BlockerRef { + full_id: String, + short_id: String, +} + +// --------------------------------------------------------------------------- +// Askama templates +// --------------------------------------------------------------------------- + +#[derive(Template)] +#[template(path = "index.html")] +struct IndexTemplate { + all_projects: Vec, + active_project: Option, + projects: Vec, +} + +#[derive(Template)] +#[template(path = "project.html")] +struct ProjectTemplate { + all_projects: Vec, + active_project: Option, + project_name: String, + stats_open: usize, + stats_in_progress: usize, + stats_closed: usize, + next_up: Vec, + page_tasks: Vec, + all_labels: Vec, + filter_status: Option, + filter_priority: Option, + filter_effort: Option, + filter_label: Option, + filter_search: String, + page: usize, + total_pages: usize, + pagination_pages: Vec, +} + +impl ProjectTemplate { + /// Build a pagination link preserving current filter query params. + fn pagination_href(&self, target_page: &usize) -> String { + let target_page = *target_page; + let mut parts = Vec::new(); + if let Some(ref s) = self.filter_status { + parts.push(format!("status={s}")); + } + if let Some(ref p) = self.filter_priority { + parts.push(format!("priority={p}")); + } + if let Some(ref e) = self.filter_effort { + parts.push(format!("effort={e}")); + } + if let Some(ref l) = self.filter_label { + parts.push(format!("label={l}")); + } + if !self.filter_search.is_empty() { + parts.push(format!("q={}", self.filter_search)); + } + parts.push(format!("page={target_page}")); + format!("/projects/{}?{}", self.project_name, parts.join("&")) + } +} + +#[derive(Template)] +#[template(path = "task.html")] +struct TaskTemplate { + all_projects: Vec, + active_project: Option, + project_name: String, + task: TaskView, + blockers_open: Vec, + blockers_resolved: Vec, + subtasks: Vec, +} + +#[derive(Template)] +#[template(path = "error.html")] +struct ErrorTemplate { + all_projects: Vec, + active_project: Option, + status_code: u16, + message: String, +} + +// --------------------------------------------------------------------------- +// Response helpers +// --------------------------------------------------------------------------- + +fn render(tmpl: impl Template) -> Response { + match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(e) => error_response(500, &format!("template render failed: {e}"), &[]), + } +} + +fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response { + let body = ErrorTemplate { + all_projects: all_projects.to_vec(), + active_project: None, + status_code: code, + message: msg.to_string(), + }; + let html = body + .render() + .unwrap_or_else(|_| format!("

{code}

{msg}

")); + let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + (status, Html(html)).into_response() +} + +fn list_projects_safe(root: &std::path::Path) -> Vec { + db::list_projects_in(root).unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Route handlers +// --------------------------------------------------------------------------- + +async fn index_handler(State(state): State) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let projects = list_projects_safe(&root); + let mut cards = Vec::with_capacity(projects.len()); + + for name in &projects { + match Store::open(&root, name) { + Ok(store) => { + let tasks = store.list_tasks()?; + let open = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .count(); + let in_progress = tasks + .iter() + .filter(|t| t.status == db::Status::InProgress) + .count(); + let closed = tasks + .iter() + .filter(|t| t.status == db::Status::Closed) + .count(); + cards.push(ProjectCard::Ok { + name: name.clone(), + open, + in_progress, + closed, + total: tasks.len(), + }); + } + Err(e) => { + cards.push(ProjectCard::Err { + name: name.clone(), + error: format!("{e}"), + }); + } + } + } + + Ok(IndexTemplate { + all_projects: projects, + active_project: None, + projects: cards, + }) + }) + .await; + + match result { + Ok(Ok(tmpl)) => render(tmpl), + Ok(Err(e)) => error_response(500, &format!("{e}"), &[]), + Err(e) => error_response(500, &format!("join error: {e}"), &[]), + } +} + +#[derive(serde::Deserialize)] +struct ProjectQuery { + status: Option, + priority: Option, + effort: Option, + label: Option, + q: Option, + page: Option, +} + +async fn project_handler( + State(state): State, + AxumPath(name): AxumPath, + Query(query): Query, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let all_projects = list_projects_safe(&root); + let store = Store::open(&root, &name)?; + let tasks = store.list_tasks()?; + + // Stats from the full unfiltered set. + let stats_open = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .count(); + let stats_in_progress = tasks + .iter() + .filter(|t| t.status == db::Status::InProgress) + .count(); + let stats_closed = tasks + .iter() + .filter(|t| t.status == db::Status::Closed) + .count(); + + // Collect distinct labels for the filter dropdown. + let mut label_set: HashSet = HashSet::new(); + for t in &tasks { + for l in &t.labels { + label_set.insert(l.clone()); + } + } + let mut all_labels: Vec = label_set.into_iter().collect(); + all_labels.sort(); + + // Next-up scoring (top 5 open tasks). + let open_tasks: Vec = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .map(|t| score::TaskInput { + id: t.id.as_str().to_string(), + title: t.title.clone(), + priority_score: t.priority.score(), + effort_score: t.effort.score(), + priority_label: db::priority_label(t.priority).to_string(), + effort_label: db::effort_label(t.effort).to_string(), + }) + .collect(); + + let edges: Vec<(String, String)> = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .flat_map(|t| { + t.blockers + .iter() + .map(|b| (t.id.as_str().to_string(), b.as_str().to_string())) + .collect::>() + }) + .collect(); + + let parents_with_open_children: HashSet = tasks + .iter() + .filter(|t| t.status == db::Status::Open) + .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string())) + .collect(); + + let scored = score::rank( + &open_tasks, + &edges, + &parents_with_open_children, + score::Mode::Impact, + 5, + ); + + let next_up: Vec = scored + .into_iter() + .map(|s| ScoredEntry { + short_id: TaskId::display_id(&s.id), + id: s.id, + title: s.title, + score: format!("{:.2}", s.score), + }) + .collect(); + + // Apply filters. + let mut filtered: Vec<&db::Task> = tasks.iter().collect(); + + if let Some(ref s) = query.status { + if !s.is_empty() { + if let Ok(parsed) = db::parse_status(s) { + filtered.retain(|t| t.status == parsed); + } + } + } + if let Some(ref p) = query.priority { + if !p.is_empty() { + if let Ok(parsed) = db::parse_priority(p) { + filtered.retain(|t| t.priority == parsed); + } + } + } + if let Some(ref e) = query.effort { + if !e.is_empty() { + if let Ok(parsed) = db::parse_effort(e) { + filtered.retain(|t| t.effort == parsed); + } + } + } + if let Some(ref l) = query.label { + if !l.is_empty() { + filtered.retain(|t| t.labels.iter().any(|x| x == l)); + } + } + let search_term = query.q.clone().unwrap_or_default(); + if !search_term.is_empty() { + let q = search_term.to_ascii_lowercase(); + filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q)); + } + + // Sort: priority score ascending, then created_at. + filtered.sort_by_key(|t| (t.priority.score(), t.created_at.clone())); + + // Pagination. + let total = filtered.len(); + let total_pages = if total == 0 { + 1 + } else { + total.div_ceil(PAGE_SIZE) + }; + let page = query.page.unwrap_or(1).clamp(1, total_pages); + let start = (page - 1) * PAGE_SIZE; + let end = (start + PAGE_SIZE).min(total); + + let page_tasks: Vec = filtered[start..end] + .iter() + .map(|t| TaskRow { + full_id: t.id.as_str().to_string(), + short_id: t.id.short(), + status: db::status_label(t.status).to_string(), + priority: db::priority_label(t.priority).to_string(), + effort: db::effort_label(t.effort).to_string(), + title: t.title.clone(), + }) + .collect(); + + Ok(ProjectTemplate { + all_projects, + active_project: Some(name), + project_name: store.project_name().to_string(), + stats_open, + stats_in_progress, + stats_closed, + next_up, + page_tasks, + all_labels, + filter_status: query.status, + filter_priority: query.priority, + filter_effort: query.effort, + filter_label: query.label, + filter_search: search_term, + page, + total_pages, + pagination_pages: (1..=total_pages).collect(), + }) + }) + .await; + + match result { + Ok(Ok(tmpl)) => render(tmpl), + Ok(Err(e)) => error_response(500, &format!("{e}"), &[]), + Err(e) => error_response(500, &format!("join error: {e}"), &[]), + } +} + +async fn task_handler( + State(state): State, + AxumPath((name, id)): AxumPath<(String, String)>, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let all_projects = list_projects_safe(&root); + let store = Store::open(&root, &name)?; + + let task_id = db::resolve_task_id(&store, &id, false)?; + let task = store + .get_task(&task_id, false)? + .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?; + + // Partition blockers. + let partition = db::partition_blockers(&store, &task.blockers)?; + let blockers_open: Vec = partition + .open + .iter() + .map(|b| BlockerRef { + full_id: b.as_str().to_string(), + short_id: b.short(), + }) + .collect(); + let blockers_resolved: Vec = partition + .resolved + .iter() + .map(|b| BlockerRef { + full_id: b.as_str().to_string(), + short_id: b.short(), + }) + .collect(); + + // Find subtasks. + let all_tasks = store.list_tasks()?; + let subtasks: Vec = all_tasks + .iter() + .filter(|t| t.parent.as_ref() == Some(&task_id)) + .map(|t| TaskRow { + full_id: t.id.as_str().to_string(), + short_id: t.id.short(), + status: db::status_label(t.status).to_string(), + priority: db::priority_label(t.priority).to_string(), + effort: db::effort_label(t.effort).to_string(), + title: t.title.clone(), + }) + .collect(); + + let task_view = TaskView { + full_id: task.id.as_str().to_string(), + short_id: task.id.short(), + title: task.title.clone(), + description: task.description.clone(), + task_type: task.task_type.clone(), + status: db::status_label(task.status).to_string(), + priority: db::priority_label(task.priority).to_string(), + effort: db::effort_label(task.effort).to_string(), + created_at: task.created_at.clone(), + updated_at: task.updated_at.clone(), + labels: task.labels.clone(), + logs: task + .logs + .iter() + .map(|l| LogView { + timestamp: l.timestamp.clone(), + message: l.message.clone(), + }) + .collect(), + }; + + Ok(TaskTemplate { + all_projects, + active_project: Some(name), + project_name: store.project_name().to_string(), + task: task_view, + blockers_open, + blockers_resolved, + subtasks, + }) + }) + .await; + + match result { + Ok(Ok(tmpl)) => render(tmpl), + Ok(Err(e)) => error_response(500, &format!("{e}"), &[]), + Err(e) => error_response(500, &format!("join error: {e}"), &[]), + } +} + +async fn static_oat_css() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")], + include_bytes!("../../static/oat.min.css").as_slice(), + ) +} + +async fn static_td_css() -> impl IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")], + include_bytes!("../../static/td.css").as_slice(), + ) +} + +async fn static_js() -> impl IntoResponse { + ( + [( + axum::http::header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + )], + include_bytes!("../../static/oat.min.js").as_slice(), + ) +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) -> Result<()> { + let data_root = db::data_root()?; + let state = AppState { + data_root: Arc::new(data_root), + }; + + let app = Router::new() + .route("/", get(index_handler)) + .route("/projects/{name}", get(project_handler)) + .route("/projects/{name}/tasks/{id}", get(task_handler)) + .route("/static/oat.min.css", get(static_oat_css)) + .route("/static/td.css", get(static_td_css)) + .route("/static/oat.min.js", get(static_js)) + .with_state(state); + + let addr = format!("{host}:{port}"); + let root_url = format!("http://{addr}"); + + // Resolve current project for a convenience URL. + let project_url = match explicit_project { + Some(p) => Some(format!("{root_url}/projects/{p}")), + None => db::try_open(cwd) + .ok() + .flatten() + .map(|s| format!("{root_url}/projects/{}", s.project_name())), + }; + + eprintln!("listening on {root_url}"); + if let Some(ref url) = project_url { + eprintln!("project: {url}"); + } + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async { + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app).await?; + Ok(()) + }) +} diff --git a/static/oat.min.css b/static/oat.min.css new file mode 100644 index 0000000000000000000000000000000000000000..20efb2e0fb0882cfcd73172f7a0cc4f8cc6f7c62 --- /dev/null +++ b/static/oat.min.css @@ -0,0 +1 @@ +@layer theme,base,components,animations,utilities;@layer base{*,*:before,*:after{box-sizing:border-box;-webkit-tap-highlight-color:transparent}*{margin:0}html{tab-size:4}body,dialog,[popover]{font-family:var(--font-sans);font-size:var(--text-regular);line-height:var(--leading-normal);color:var(--foreground)}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased}main{padding-block-start:var(--space-8)}img,picture,video,canvas,svg{max-width:100%}p,h1,h2,h3,h4,h5,h6{overflow-wrap:break-word}h1,h2,h3,h4,h5,h6{font-weight:var(--font-semibold);line-height:1.25;&:first-child{margin-block-start:0}}h1{font-size:var(--text-1);margin:var(--space-10) 0 var(--space-6)}h2{font-size:var(--text-2);margin:var(--space-8) 0 var(--space-5)}h3{font-size:var(--text-3);margin:var(--space-6) 0 var(--space-4)}h4{font-size:var(--text-4);margin:var(--space-5) 0 var(--space-3)}h5{font-size:var(--text-5);margin:var(--space-4) 0 var(--space-2)}h6{font-size:var(--text-regular);margin:var(--space-4) 0 var(--space-2)}p{margin-block-end:var(--space-4);&:last-child{margin-block-end:0}}a{color:var(--primary);text-decoration:underline;text-underline-offset:2px;transition:color var(--transition-fast);&:hover{color:rgb(from var(--primary) r g b / .8)}}strong,b{font-weight:var(--font-semibold)}em,i{font-style:italic}small{font-size:var(--text-7)}code{font-family:var(--font-mono);font-size:.875em;padding:calc(var(--space-1) / 2) var(--space-1);background-color:var(--faint);border-radius:var(--radius-small)}pre{font-family:var(--font-mono);padding:var(--space-4);background-color:var(--faint);border-radius:var(--radius-medium);overflow-x:auto;margin-block-end:var(--space-4);code{padding:0;background:none;border-radius:0}}blockquote{border-inline-start:4px solid var(--border);padding-inline-start:var(--space-4);margin:var(--space-4) 0;color:var(--muted-foreground);font-style:italic}hr{border:none;border-top:1px solid var(--border);margin:var(--space-2) 0}ul,ol{padding-inline-start:var(--space-6);margin-block-end:var(--space-4)}ul{list-style-type:disc}ol{list-style-type:decimal}li{margin-block-end:var(--space-1)}mark{background-color:rgb(from var(--warning) r g b / .3);padding:calc(var(--space-1) / 2) var(--space-1);border-radius:var(--radius-small)}[hidden]{display:none}:focus-visible{outline:2px solid var(--ring);outline-offset:2px}:disabled{opacity:.5;cursor:not-allowed}}@layer theme{:root{color-scheme:light dark;--background: light-dark(#fff, #09090b);--foreground: light-dark(#09090b, #fafafa);--card: light-dark(#fff, #18181b);--card-foreground: light-dark(#09090b, #fafafa);--primary: light-dark(#574747, #fafafa);--primary-foreground: light-dark(#fafafa, #18181b);--secondary: light-dark(#f4f4f5, #27272a);--secondary-foreground: light-dark(#574747, #fafafa);--muted: light-dark(#f4f4f5, #27272a);--muted-foreground: light-dark(#71717a, #a1a1aa);--faint: light-dark(#fafafa, #1e1e21);--faint-foreground: light-dark(#a1a1aa, #71717a);--accent: light-dark(#f4f4f5, #27272a);--danger: light-dark(#d32f2f, #f4807b);--danger-foreground: light-dark(#fafafa, #18181b);--success: light-dark(#008032, #6cc070);--success-foreground: light-dark(#fafafa, #18181b);--warning: light-dark(#a65b00, #f0a030);--warning-foreground: #09090b;--border: light-dark(#d4d4d8, #52525b);--input: light-dark(#d4d4d8, #52525b);--ring: light-dark(#574747, #d4d4d8);--space-1: .25rem;--space-2: .5rem;--space-3: .75rem;--space-4: 1rem;--space-5: 1.25rem;--space-6: 1.5rem;--space-8: 2rem;--space-10: 2.5rem;--space-12: 3rem;--space-14: 3.5rem;--space-16: 4rem;--space-18: 4.5rem;--radius-small: .125rem;--radius-medium: .375rem;--radius-large: .75rem;--radius-full: 9999px;--bar-height: .5rem;--font-sans: system-ui, sans-serif;--font-mono: ui-monospace, Consolas, monospace;--text-1: clamp(1.75rem, 1.5rem + 1.1vw, 2.25rem);--text-2: clamp(1.5rem, 1.3rem + .8vw, 1.875rem);--text-3: clamp(1.25rem, 1.1rem + .5vw, 1.5rem);--text-4: clamp(1.125rem, 1.05rem + .3vw, 1.25rem);--text-5: 1.125rem;--text-6: 1rem;--text-7: .875rem;--text-8: .75rem;--text-regular: var(--text-6);--leading-normal: 1.5;--font-normal: 400;--font-medium: 500;--font-semibold: 600;--font-bold: 600;--shadow-small: 0 1px 2px 0 rgb(0 0 0 / .05);--shadow-medium: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--shadow-large: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--transition-fast: .12s cubic-bezier(.4, 0, .2, 1);--transition: .2s cubic-bezier(.4, 0, .2, 1);--z-dropdown: 50;--z-modal: 200}}@layer animations{.animate-pop-in{opacity:1;transform:perspective(1000px) rotateX(0) translateZ(0);transition:opacity .15s cubic-bezier(.4,0,.2,1),transform .15s cubic-bezier(.4,0,.2,1),overlay .15s cubic-bezier(.4,0,.2,1) allow-discrete,display .15s cubic-bezier(.4,0,.2,1) allow-discrete;@starting-style{opacity:0;transform:perspective(1000px) rotateX(-15deg) translateZ(-80px)}&[data-state=closing]{opacity:0;transform:perspective(1000px) rotateX(-15deg) translateZ(-80px)}&[data-state=closing]::backdrop{opacity:0}}dialog::backdrop{opacity:1;transition:opacity .15s cubic-bezier(.4,0,.2,1);@starting-style{opacity:0}}.animate-slide-in{opacity:1;transform:translate(0);transition:opacity .15s cubic-bezier(.16,1,.3,1),transform .15s cubic-bezier(.16,1,.3,1);@starting-style{opacity:0;transform:translate(100%)}&[data-state=closing]{opacity:0;transform:translate(100%)}}}@layer components{figure[data-variant=avatar]:not([role=group]){display:inline-flex;align-items:center;justify-content:center;width:var(--sz, 2.5rem);height:var(--sz, 2.5rem);color:var(--primary);background-color:var(--muted);border-radius:var(--radius-full);font-weight:var(--font-medium);overflow:hidden;>img{width:100%;height:100%;object-fit:cover}&.small{--sz: 2rem}&.large{--sz: 3.25rem}}figure[data-variant=avatar][role=group]{display:inline-flex;align-items:center;margin:0;& figure[data-variant=avatar]{margin-inline-end:calc(var(--space-5) * -1);border:2px solid var(--background);&:last-child{margin-inline-end:0}}&.small{--sz: 2rem;& figure[data-variant=avatar]{margin-inline-end:calc(var(--space-4) * -.8);border-width:1px}}&.large{--sz: 3.25rem;& figure[data-variant=avatar]{margin-inline-end:calc(var(--space-6) * -1)}}}}@layer base{:is(button,[type=submit],[type=reset],[type=button],a.button),::file-selector-button{--_hov: color-mix(in srgb, var(--primary), white 25%);display:inline-flex;align-items:center;justify-content:center;gap:var(--space-2);padding:var(--space-2) var(--space-4);font-size:var(--text-7);font-weight:var(--font-medium);line-height:var(--leading-normal);white-space:nowrap;text-decoration:none;background-color:var(--primary);color:var(--primary-foreground);border-radius:var(--radius-medium);border:1px solid;border-color:rgb(from #fff r g b / .15) rgb(from #000 r g b / .2) rgb(from #000 r g b / .2) rgb(from #fff r g b / .15);transition:background-color var(--transition-fast),opacity var(--transition-fast),transform var(--transition-fast);&:not(:disabled){cursor:pointer}&:hover:not(:disabled){background-color:var(--_hov)}&:active:not(:disabled){transform:translate(1px,1px)}&[data-variant=secondary]{--_hov: color-mix(in srgb, var(--secondary), black 10%);background-color:var(--secondary);color:var(--secondary-foreground);border-color:rgb(from #fff r g b / .5) rgb(from #000 r g b / .1) rgb(from #000 r g b / .1) rgb(from #fff r g b / .5)}&[data-variant=danger]{--_hov: color-mix(in srgb, var(--danger), black 15%);background-color:var(--danger);color:var(--danger-foreground)}&:is(.outline,.ghost){--_hov: var(--accent);background-color:transparent;color:var(--foreground);&[data-variant=danger]{--_hov: color-mix(in srgb, var(--danger), transparent 90%);color:var(--danger)}&[data-variant=secondary]{--_hov: color-mix(in srgb, var(--secondary), transparent 80%);color:var(--secondary-foreground)}}&.outline{border-color:var(--border);&[data-variant=danger]{border-color:var(--danger)}&[data-variant=secondary]{border-color:var(--secondary)}}&.ghost{border-color:transparent}&.small{padding:var(--space-1) var(--space-3);font-size:var(--text-8)}&.large{height:3rem;padding:0 var(--space-6);font-size:var(--text-regular)}&.icon{width:2.5rem;padding:0;&.small{width:2rem}&.large{width:3rem}}}::file-selector-button{background-color:transparent;color:var(--foreground);border:1px solid var(--border)}::file-selector-button:hover{background-color:var(--accent)}}@layer components{menu.buttons{list-style-type:none;padding-inline-start:0;display:inline-flex;>li{&:first-child>*{border-start-start-radius:var(--radius-medium);border-end-start-radius:var(--radius-medium)}&:last-child>*{border-start-end-radius:var(--radius-medium);border-end-end-radius:var(--radius-medium)}>*{border-radius:0}&:not(:last-child)>*{border-inline-end:1px solid rgb(from var(--primary-foreground) r g b / .2)}}}}@layer base{label{display:block;font-size:var(--text-7);font-weight:var(--font-medium);&:has(input:where([type=checkbox],[type=radio])){display:inline-flex;align-items:center;gap:var(--space-2);font-weight:var(--font-normal)}}:where(input:not([type=checkbox],[type=radio],[type=range],[type=file],[type=color]),textarea,select){width:100%;margin-block-start:var(--space-1);padding:var(--space-2) var(--space-3);font-size:var(--text-7);line-height:var(--leading-normal);background-color:var(--background);color:var(--foreground);border:1px solid var(--input);border-radius:var(--radius-medium);transition:border-color var(--transition-fast),box-shadow var(--transition-fast);&::placeholder{color:var(--muted-foreground)}&:focus{outline:none;border-color:var(--ring);box-shadow:0 0 0 2px rgb(from var(--ring) r g b / .2);z-index:1}&:disabled{background-color:var(--muted)}&:is([aria-invalid=true],:user-invalid){border-color:var(--danger);&:focus{box-shadow:0 0 0 2px rgb(from var(--danger) r g b / .2)}}}textarea{height:auto;min-height:5rem;padding:var(--space-3);resize:vertical}select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right var(--space-2) center;padding-inline-end:var(--space-6)}input:where([type=checkbox],[type=radio]){appearance:none;width:1rem;height:1rem;margin:0;position:relative;background-color:var(--background);border:1px solid var(--input);transition:background-color var(--transition-fast),border-color var(--transition-fast);&:checked{background-color:var(--primary);border-color:var(--primary);&:after{content:"";position:absolute;inset:0;background-color:var(--primary-foreground);mask-position:center;mask-repeat:no-repeat;mask-size:100%}}}input[type=checkbox]{border-radius:var(--radius-small);&:checked:after{mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='4'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E")}&[role=switch]{--switch-height: calc(var(--bar-height) * 3);--switch-inset: 2px;--switch-thumb: calc(var(--switch-height) - var(--switch-inset) * 3);width:calc(var(--switch-height) * 2);height:var(--switch-height);border-radius:var(--radius-full);background-color:var(--input);&:before{content:"";position:absolute;top:50%;left:var(--switch-inset);transform:translateY(-50%);width:var(--switch-thumb);height:var(--switch-thumb);background-color:var(--background);border-radius:var(--radius-full);transition:transform var(--transition);box-shadow:var(--shadow-small)}&:checked{background-color:var(--primary);&:after{content:none}&:before{transform:translateY(-50%) translate(var(--switch-height))}}}}input[type=radio]{border-radius:var(--radius-full);&:checked:after{mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='4' fill='currentColor'/%3E%3C/svg%3E")}}:where(input:where([type=checkbox],[type=radio],[type=range]),select):not(:disabled),label:has(input:where([type=checkbox],[type=radio]):not(:disabled)){cursor:pointer}input[type=range]{width:100%;height:var(--bar-height);appearance:none;background:var(--muted);border-radius:var(--radius-full);&::-webkit-slider-thumb{appearance:none;width:1.25rem;height:1.25rem;background:var(--primary);border-radius:var(--radius-full);transition:transform var(--transition-fast);&:hover{transform:scale(1.1)}}&::-moz-range-thumb{width:1.25rem;height:1.25rem;background:var(--primary);border:none;border-radius:var(--radius-full)}}fieldset{border:1px solid var(--border);border-radius:var(--radius-medium);padding:var(--space-4);margin-block-end:var(--space-4)}legend{font-size:var(--text-7);font-weight:var(--font-medium);padding:0 var(--space-2)}}@layer components{fieldset.group{display:flex;align-items:stretch;border:none;padding:0;margin:0;>:is(input,textarea,select){flex:1;margin-block-start:0;&:not(:focus):not(:last-child){border-inline-end-color:transparent}}>:is(input,textarea,select,button){border-radius:0;&:first-child{border-radius:var(--radius-medium) 0 0 var(--radius-medium)}&:last-child{border-radius:0 var(--radius-medium) var(--radius-medium) 0}}>legend{float:inline-start;display:inline-flex;align-items:center;padding:0 var(--space-3);line-height:var(--leading-normal);font-weight:var(--font-normal);color:var(--muted-foreground);background-color:var(--muted);border:1px solid var(--input);border-inline-end:none;border-radius:var(--radius-medium) 0 0 var(--radius-medium)}}[data-field]{margin-block-end:var(--space-4);[data-hint],.error{font-size:var(--text-8);font-weight:var(--font-normal);color:var(--muted-foreground);margin-block-start:var(--space-1)}.error{display:none}&[data-field=error] .error{display:block;color:var(--danger)}}}@layer base{.table{min-width:320px;width:100%;overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:var(--text-7)}thead{border-bottom:1px solid var(--border)}th,td{overflow-wrap:break-word}th{padding:var(--space-3) var(--space-2);text-align:start;font-weight:var(--font-medium);color:var(--muted-foreground)}td{padding:var(--space-3) var(--space-2)}tbody tr{border-bottom:1px solid var(--border);transition:background-color var(--transition-fast);&:last-child{border-bottom:none}&:hover{background-color:rgb(from var(--muted) r g b / .5)}}}@layer base{progress{appearance:none;width:100%;height:var(--bar-height);border:none;border-radius:var(--radius-full);overflow:hidden;background-color:var(--muted);&::-webkit-progress-bar{background-color:var(--muted);border-radius:var(--radius-full)}&::-webkit-progress-value{background-color:var(--primary);border-radius:var(--radius-full);transition:width var(--transition)}&::-moz-progress-bar{background-color:var(--primary);border-radius:var(--radius-full)}}meter{appearance:none;width:100%;height:var(--bar-height);border:none;border-radius:var(--radius-full);overflow:hidden;background:var(--muted);&::-webkit-meter-bar{background:var(--muted);border:none;border-radius:var(--radius-full);height:var(--bar-height)}&::-webkit-meter-optimum-value,&::-webkit-meter-suboptimum-value,&::-webkit-meter-even-less-good-value{border-radius:var(--radius-full)}&::-webkit-meter-optimum-value{background:var(--success)}&::-webkit-meter-suboptimum-value{background:var(--warning)}&::-webkit-meter-even-less-good-value{background:var(--danger)}&::-moz-meter-bar{background:var(--success);border-radius:var(--radius-full)}&:-moz-meter-sub-optimum::-moz-meter-bar{background:var(--warning)}&:-moz-meter-sub-sub-optimum::-moz-meter-bar{background:var(--danger)}}}@layer components{[aria-busy=true]{&:before{content:"";display:inline-block;inset:0;margin:auto;width:1.5rem;height:1.5rem;border:2px solid var(--muted);border-top-color:var(--primary);border-radius:var(--radius-full);animation:spin 1s linear infinite;text-align:center}&[data-spinner~=small]:before{width:1rem;height:1rem}&[data-spinner~=large]:before{width:2rem;height:2rem;border-width:3px}&[data-spinner~=overlay]{position:relative;>*{opacity:.3;pointer-events:none}&:before{position:absolute;inset:0;margin:auto;z-index:1}}}@keyframes spin{to{transform:rotate(360deg)}}}@layer components{:root{--grid-cols: 12;--grid-gap: 1.5rem;--container-max: 1280px;--container-pad: 1rem}.container{width:100%;max-width:var(--container-max);margin-inline:auto;padding-inline:var(--container-pad)}.row{display:grid;grid-template-columns:repeat(var(--grid-cols),1fr);gap:var(--grid-gap);width:100%}.col,[class*=col-]{grid-column-end:span var(--span, var(--grid-cols))}.col-1{--span: 1}.col-2{--span: 2}.col-3{--span: 3}.col-4{--span: 4}.col-5{--span: 5}.col-6{--span: 6}.col-7{--span: 7}.col-8{--span: 8}.col-9{--span: 9}.col-10{--span: 10}.col-11{--span: 11}.col-12{--span: 12}.offset-1{grid-column-start:2}.offset-2{grid-column-start:3}.offset-3{grid-column-start:4}.offset-4{grid-column-start:5}.offset-5{grid-column-start:6}.offset-6{grid-column-start:7}.col-end{grid-column-start:span var(--span, 1);grid-column-end:-1}@media(max-width:768px){.row{--grid-cols: 4;--grid-gap: 1rem}.col,[class*=col-]{--span: 4}[class*=offset-]{grid-column-start:auto}}}@layer components{.card{background-color:var(--card);color:var(--card-foreground);border:1px solid var(--border);border-radius:var(--radius-medium);box-shadow:var(--shadow-small);padding:var(--space-6);overflow:hidden}}@layer components{[role=alert]{position:relative;display:flex;gap:var(--space-3);padding:var(--space-4) var(--space-6);background-color:var(--background);border:1px solid var(--border);border-radius:var(--radius-medium);font-size:var(--text-7);&[data-variant]{border:none}&[data-variant=error],&[data-variant=danger]{color:var(--danger);background-color:light-dark(color-mix(in srgb,var(--danger) 8%,transparent),color-mix(in srgb,var(--danger) 20%,transparent));& a{color:var(--danger)}}&[data-variant=success]{color:var(--success);background-color:light-dark(color-mix(in srgb,var(--success) 8%,transparent),color-mix(in srgb,var(--success) 20%,transparent));& a{color:var(--success)}}&[data-variant=warning]{color:var(--warning);background-color:light-dark(color-mix(in srgb,var(--warning) 8%,transparent),color-mix(in srgb,var(--warning) 20%,transparent));& a{color:var(--warning)}}}}@layer components{.badge{display:inline-flex;align-items:center;gap:var(--space-1);padding:var(--space-1) var(--space-4);font-size:var(--text-8);font-weight:var(--font-medium);line-height:var(--leading-normal);background-color:var(--primary);color:var(--primary-foreground);border-radius:var(--radius-full);&.secondary{background-color:var(--secondary);color:var(--secondary-foreground)}&.outline{background-color:transparent;color:var(--foreground);border:1px solid var(--border)}&.success{color:var(--success);background-color:light-dark(color-mix(in srgb,var(--success) 10%,transparent),color-mix(in srgb,var(--success) 30%,transparent))}&.warning{color:var(--warning);background-color:light-dark(color-mix(in srgb,var(--warning) 10%,transparent),color-mix(in srgb,var(--warning) 30%,transparent))}&.danger{color:var(--danger);background-color:light-dark(color-mix(in srgb,var(--danger) 10%,transparent),color-mix(in srgb,var(--danger) 30%,transparent))}}}@layer components{details{border:1px solid var(--border);border-radius:var(--radius-medium);overflow:hidden;+details{margin-top:-1px;border-start-start-radius:0;border-start-end-radius:0}&:has(+details){border-end-start-radius:0;border-end-end-radius:0}&[open] summary{border-bottom:1px solid var(--border)}}summary{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);padding:var(--space-4);font-weight:var(--font-medium);cursor:pointer;user-select:none;transition:background-color var(--transition-fast);&:hover{background-color:var(--muted)}&::-webkit-details-marker,&::marker{display:none}&:after{content:"";width:1em;height:1em;flex-shrink:0;background-color:currentColor;mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");mask-size:contain;mask-repeat:no-repeat;transition:transform var(--transition-fast)}details[open] &:after{transform:rotate(180deg)}}details>*:not(summary){margin:var(--space-4)}}@layer components{[role=tablist]{display:inline-flex;align-items:center;gap:var(--space-1);padding:var(--space-1);background-color:var(--muted);border-radius:var(--radius-medium)}[role=tab]{display:inline-flex;align-items:center;justify-content:center;padding:var(--space-2) var(--space-3);font-size:var(--text-7);font-weight:var(--font-medium);white-space:nowrap;background-color:transparent;color:var(--foreground);border:none;border-radius:calc(var(--radius-medium) - 2px);cursor:pointer;transition:background-color var(--transition-fast),color var(--transition-fast);&:hover{color:var(--muted-foreground)}&[aria-selected=true]{background-color:var(--background);box-shadow:var(--shadow-small)}}[role=tabpanel]{padding:var(--space-4) 0;&:focus-visible{outline:none}}}@layer components{dialog{position:fixed;inset:0;z-index:var(--z-modal);width:min(100% - 2rem,32rem);max-height:85vh;margin:auto;padding:0;background-color:var(--card);border:1px solid var(--border);border-radius:var(--radius-large);box-shadow:var(--shadow-large);overflow:hidden;opacity:0;transform:scale(.95);transition:opacity .15s ease,transform .15s ease,overlay .15s ease allow-discrete,display .15s ease allow-discrete;&[open]{opacity:1;transform:scale(1)}@starting-style{&[open]{opacity:0;transform:scale(.95)}}&::backdrop{background-color:#0000;transition:background-color .15s ease,overlay .15s ease allow-discrete,display .15s ease allow-discrete}&[open]::backdrop{background-color:#00000080}@starting-style{&[open]::backdrop{background-color:#0000}}>header,>form>header{display:flex;flex-direction:column;gap:var(--space-1);padding:var(--space-6);padding-block-end:0;>h1,>h2,>h3,>h4,>h5,>h6{margin-block-end:0}>p{font-size:var(--text-7);color:var(--muted-foreground);margin-block-end:0}}>p,>div,>section,>form>p,>form>div,>form>section{padding:var(--space-6);overflow-y:auto}>footer,>form>footer{display:flex;justify-content:flex-end;gap:var(--space-2);padding:var(--space-6);padding-block-start:0}}}@layer components{ot-dropdown{[popover]{position:fixed;margin:0;min-width:12rem;background-color:var(--background);border:1px solid var(--border);border-radius:var(--radius-medium);box-shadow:var(--shadow-medium);opacity:0;transform:translateY(-4px);transition:opacity .15s ease-out,transform .15s ease-out,display .15s allow-discrete,overlay .15s allow-discrete;&:popover-open{opacity:1;transform:translateY(0)}@starting-style{&:popover-open{opacity:0;transform:translateY(-4px)}}}[role=menuitem]{display:flex;align-items:center;justify-content:start;gap:var(--space-2);width:100%;padding:var(--space-2) var(--space-3);font-size:var(--text-7);text-align:start;color:var(--foreground);background:none;border:none;border-radius:var(--radius-small);cursor:pointer;&:hover,&:focus{background-color:var(--accent);outline:none}}}}@layer components{.toast-container{position:fixed;display:flex;flex-direction:column;pointer-events:none;margin:0;padding:0;border:none;background:transparent;overflow:visible;&::backdrop{display:none}&[data-placement=top-left]{inset:var(--space-4) auto auto var(--space-4)}&[data-placement=top-center]{inset:var(--space-4) auto auto 50%;transform:translate(-50%)}&[data-placement=top-right]{inset:var(--space-4) var(--space-4) auto auto}&[data-placement=bottom-left]{inset:auto auto var(--space-4) var(--space-4);flex-direction:column-reverse}&[data-placement=bottom-center]{inset:auto auto var(--space-4) 50%;transform:translate(-50%);flex-direction:column-reverse}&[data-placement=bottom-right]{inset:auto var(--space-4) var(--space-4) auto;flex-direction:column-reverse}}.toast{--transition: .3s;--transition-in: calc(var(--transition) - 50ms);padding:var(--space-5) var(--space-4);max-width:28rem;min-width:20rem;pointer-events:auto;background-color:var(--card);border:1px solid var(--border);border-inline-start-width:var(--space-1);border-inline-start-style:solid;border-radius:var(--radius-medium);box-shadow:var(--shadow-small);transition:opacity var(--transition-in),transform var(--transition-in),margin var(--transition-in);line-height:1;.toast-title{font-weight:600;margin:0 0 var(--space-3) 0}.toast-message{color:var(--muted-foreground)}&[data-variant=success]{border-inline-start-color:var(--success);.toast-title{color:var(--success)}}&[data-variant=danger]{border-inline-start-color:var(--danger);.toast-title{color:var(--danger)}}&[data-variant=warning]{border-inline-start-color:var(--warning);.toast-title{color:var(--warning)}}>[data-close]{margin-inline-start:auto;background:none;border:none;padding:0;cursor:pointer;opacity:.5;&:hover{opacity:1}}margin:var(--space-2) 0;&[data-entering]{opacity:0;transform:translateY(-1rem)}&[data-exiting]{opacity:0;margin:0;padding-block:0;max-height:0;overflow:hidden;transition:opacity var(--transition),margin var(--transition),padding var(--transition),max-height var(--transition)}}}@layer components{[data-sidebar-layout]{display:grid;grid-template-columns:14rem 1fr;grid-template-rows:auto 1fr;height:100dvh;>main{grid-row:2;min-width:0;overflow-y:auto}>aside[data-sidebar]{grid-row:2;min-height:0;z-index:1;background-color:var(--background);border-inline-end:1px solid var(--border);box-shadow:var(--shadow-medium);display:flex;flex-direction:column;>:is(header,footer){flex-shrink:0;padding:var(--space-3)}>footer{margin-block-start:auto}>nav{flex:1;min-height:0;overflow-y:auto;padding:var(--space-3) var(--space-2);font-size:var(--text-7);ul{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:var(--space-1);li{margin:0}}a{display:flex;gap:var(--space-2);padding:var(--space-1) var(--space-3);color:var(--foreground);text-decoration:none;border-radius:var(--radius-small);transition:background-color var(--transition-fast);&:is(:hover,[aria-current]){background-color:var(--accent)}}details{border:none;overflow:visible;+details{margin-top:0}&[open] summary{border-bottom:none}>ul{margin-inline-start:var(--space-4);padding:var(--space-1) 0}}summary{justify-content:flex-start;padding:var(--space-2) var(--space-3);border-radius:var(--radius-small);&:after{width:.75rem;height:.75rem;margin-inline-start:auto}}}}>nav[data-topnav]{grid-column:1 / -1}}nav[data-topnav]{position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-4);background-color:var(--background);border-bottom:1px solid var(--border);box-shadow:var(--shadow-small);a{text-decoration:none}}:is([data-sidebar-toggle],[data-sidebar-header]){display:none}[data-sidebar-toggle]{padding:0 var(--space-1);background:none;border:1px solid var(--border);border-radius:var(--radius-small)}@media(min-width:769px){[data-sidebar-layout=always]{transition:grid-template-columns var(--transition);[data-sidebar-toggle]{display:inline-block}>aside[data-sidebar]{transform:translate(0);opacity:1;transition:transform var(--transition),opacity var(--transition),visibility var(--transition)}&[data-sidebar-open]{grid-template-columns:0px 1fr;gap:0;>aside[data-sidebar]{overflow:hidden;min-width:0;transform:translate(-100%);opacity:0;visibility:hidden;border-inline-end:none}}}}@media(max-width:768px){[data-sidebar-layout]{grid-template-columns:1fr;>main{grid-column:1}>aside[data-sidebar]{grid-column:1;z-index:2;width:16rem;transform:translate(-100%);transition:transform var(--transition);box-shadow:var(--shadow-large)}&[data-sidebar-open]>aside[data-sidebar]{transform:translate(0)}}[data-sidebar-toggle]{display:inline-block}[data-sidebar-header]{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)}}}@layer components{[role=status].skeleton{--_c: light-dark( color-mix(in srgb, var(--muted) 15%, white), color-mix(in srgb, var(--muted) 90%, var(--foreground)) );margin-block-end:var(--space-3);background:var(--muted);border-radius:var(--radius-medium);animation:anim 2s infinite;background-size:200% 100%;background-image:linear-gradient(90deg,var(--muted) 0%,var(--_c) 50%,var(--muted) 100%);&.box{width:4rem;height:4rem}&.line{height:1rem;width:100%}}[role=status].skeleton:last-child{margin-block-end:0}@keyframes anim{0%{background-position:200% 0}to{background-position:-200% 0}}}@layer components{[data-tooltip]{position:relative}[data-tooltip]:before,[data-tooltip]:after{position:absolute;inset-inline-start:50%;opacity:0;visibility:hidden;transition:opacity var(--transition-fast),transform var(--transition-fast),visibility var(--transition-fast);pointer-events:none;z-index:1000}[data-tooltip]:after{content:attr(data-tooltip);inset-block-end:calc(100% + 10px);transform:translate(-50%) translateY(4px);padding:var(--space-2) var(--space-3);font-size:var(--text-7);line-height:1;white-space:nowrap;background:var(--foreground);color:var(--background);border-radius:var(--radius-medium)}[data-tooltip]:before{content:"";inset-block-end:calc(100% - 5px);transform:translate(-50%) translateY(4px);border:8px solid transparent;border-top-color:var(--foreground)}[data-tooltip]:is(:hover,:focus-visible):before,[data-tooltip]:is(:hover,:focus-visible):after{opacity:1;visibility:visible;transition-delay:.7s;transform:translate(-50%) translateY(0)}}@layer utilities{.align-left{text-align:start}.align-center{text-align:center}.align-right{text-align:end}.text-light{color:var(--muted-foreground)}.text-lighter{color:var(--faint-foreground)}.flex{display:flex}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-end{justify-content:flex-end}.hstack{display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap;align-content:flex-start;height:auto;>*{margin:0}}.vstack{display:flex;flex-direction:column;gap:var(--space-3)}.gap-1{gap:var(--space-1)}.gap-2{gap:var(--space-2)}.gap-4{gap:var(--space-4)}.mt-2{margin-block-start:var(--space-2)}.mt-4{margin-block-start:var(--space-4)}.mt-6{margin-block-start:var(--space-6)}.mb-2{margin-block-end:var(--space-2)}.mb-4{margin-block-end:var(--space-4)}.mb-6{margin-block-end:var(--space-6)}.p-4{padding:var(--space-4)}.w-100{width:100%}:is(ul,ol,a).unstyled{list-style:none;text-decoration:none;padding:0}} diff --git a/static/oat.min.js b/static/oat.min.js new file mode 100644 index 0000000000000000000000000000000000000000..cd40e8d30cd248d396b67ee9557bce79bd5f08c6 --- /dev/null +++ b/static/oat.min.js @@ -0,0 +1 @@ +(()=>{var l=class extends HTMLElement{#t=!1;connectedCallback(){this.#t||(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>this.#e(),{once:!0}):this.#e())}#e(){this.#t||(this.#t=!0,this.init())}disconnectedCallback(){this.cleanup()}cleanup(){}handleEvent(t){let e=this[`on${t.type}`];e&&e.call(this,t)}keyNav(t,e,n,o,s,a=!1){let{key:r}=t,c=-1;return r===s?c=(e+1)%n:r===o?c=(e-1+n)%n:a&&(r==="Home"?c=0:r==="End"&&(c=n-1)),c>=0&&t.preventDefault(),c}emit(t,e=null){return this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,cancelable:!0,detail:e}))}$(t){return this.querySelector(t)}$$(t){return Array.from(this.querySelectorAll(t))}uid(){return Math.random().toString(36).slice(2,10)}};"commandForElement"in HTMLButtonElement.prototype||document.addEventListener("click",i=>{let t=i.target.closest("button[commandfor]");if(!t)return;let e=document.getElementById(t.getAttribute("commandfor"));if(!e)return;let n=t.getAttribute("command")||"toggle";e instanceof HTMLDialogElement&&(n==="show-modal"?e.showModal():n==="close"||e.open?e.close():e.showModal())});var u=class extends l{#t=[];#e=[];init(){let t=this.$(':scope > [role="tablist"]');if(this.#t=t?[...t.querySelectorAll('[role="tab"]')]:[],this.#e=this.$$(':scope > [role="tabpanel"]'),this.#t.length===0||this.#e.length===0){console.warn("ot-tabs: Missing tab or tabpanel elements");return}this.#t.forEach((n,o)=>{let s=this.#e[o];if(!s)return;let a=n.id||`ot-tab-${this.uid()}`,r=s.id||`ot-panel-${this.uid()}`;n.id=a,s.id=r,n.setAttribute("aria-controls",r),s.setAttribute("aria-labelledby",a)}),t.addEventListener("click",this),t.addEventListener("keydown",this);let e=this.#t.findIndex(n=>n.ariaSelected==="true");this.#i(e>=0?e:0)}onclick(t){let e=this.#t.indexOf(t.target.closest('[role="tab"]'));e>=0&&this.#i(e)}onkeydown(t){if(!t.target.closest('[role="tab"]'))return;let e=this.keyNav(t,this.activeIndex,this.#t.length,"ArrowLeft","ArrowRight");e>=0&&(this.#i(e),this.#t[e].focus())}#i(t){this.#t.forEach((e,n)=>{let o=n===t;e.ariaSelected=String(o),e.tabIndex=o?0:-1}),this.#e.forEach((e,n)=>{e.hidden=n!==t}),this.emit("ot-tab-change",{index:t,tab:this.#t[t]})}get activeIndex(){return this.#t.findIndex(t=>t.ariaSelected==="true")}set activeIndex(t){t>=0&&t{let t=this.#e.getBoundingClientRect(),e=this.#t.getBoundingClientRect();this.#t.style.top=`${t.bottom+e.height>window.innerHeight?t.top-e.height:t.bottom}px`,this.#t.style.left=`${t.left+e.width>window.innerWidth?t.right-e.width:t.left}px`})}ontoggle(t){t.newState==="open"?(this.#i(),window.addEventListener("scroll",this.#i,!0),window.addEventListener("resize",this.#i),this.#n=this.$$('[role="menuitem"]'),this.#n[0]?.focus(),this.#e.ariaExpanded="true"):(this.cleanup(),this.#n=null,this.#e.ariaExpanded="false",this.#e.focus())}onkeydown(t){if(!t.target.matches('[role="menuitem"]'))return;let e=this.#n.indexOf(t.target),n=this.keyNav(t,e,this.#n.length,"ArrowUp","ArrowDown",!0);n>=0&&this.#n[n].focus()}cleanup(){window.removeEventListener("scroll",this.#i,!0),window.removeEventListener("resize",this.#i)}};customElements.define("ot-dropdown",h);document.addEventListener("DOMContentLoaded",()=>{let i="title",t="[title]",e=n=>{let o=n.getAttribute(i);o&&(n.setAttribute("data-tooltip",o),n.hasAttribute("aria-label")||n.setAttribute("aria-label",o),n.removeAttribute(i))};document.querySelectorAll(t).forEach(e),new MutationObserver(n=>{for(let o of n){e(o.target);for(let s of o.addedNodes)s.nodeType===1&&(e(s),s.querySelectorAll(t).forEach(e))}}).observe(document.body,{childList:!0,subtree:!0,attributes:!0,attributeFilter:[i]})});document.addEventListener("click",i=>{let t=i.target.closest("[data-sidebar-toggle]");if(t){t.closest("[data-sidebar-layout]")?.toggleAttribute("data-sidebar-open");return}if(!i.target.closest("[data-sidebar]")){let e=document.querySelector("[data-sidebar-layout][data-sidebar-open]");e&&window.matchMedia("(max-width: 768px)").matches&&e.removeAttribute("data-sidebar-open")}});var d={};function E(i){if(!d[i]){let t=document.createElement("div");t.className="toast-container",t.setAttribute("popover","manual"),t.setAttribute("data-placement",i),document.body.appendChild(t),d[i]=t}return d[i]}function p(i,t={}){let{placement:e="top-right",duration:n=4e3}=t,o=E(e);i.classList.add("toast");let s;return i.onmouseenter=()=>clearTimeout(s),i.onmouseleave=()=>{n>0&&(s=setTimeout(()=>f(i,o),n))},i.setAttribute("data-entering",""),o.appendChild(i),o.showPopover(),requestAnimationFrame(()=>{requestAnimationFrame(()=>{i.removeAttribute("data-entering")})}),n>0&&(s=setTimeout(()=>f(i,o),n)),i}function f(i,t){if(i.hasAttribute("data-exiting"))return;i.setAttribute("data-exiting","");let e=()=>{i.remove(),t.children.length||t.hidePopover()};i.addEventListener("transitionend",e,{once:!0});let n=getComputedStyle(i).getPropertyValue("--transition").trim(),o=parseFloat(n),s=n.endsWith("ms")?o:o*1e3;setTimeout(e,s)}function b(i,t,e={}){let{variant:n="info",...o}=e,s=document.createElement("output");if(s.setAttribute("data-variant",n),t){let r=document.createElement("h6");r.className="toast-title",r.textContent=t,s.appendChild(r)}let a=document.createElement("div");return a.className="toast-message",a.textContent=i,s.appendChild(a),p(s,o)}function g(i,t={}){let e;if(i instanceof HTMLTemplateElement?e=i.content.firstElementChild?.cloneNode(!0):i&&(e=i.cloneNode(!0)),!!e)return e.removeAttribute("id"),p(e,t)}function v(i){i&&d[i]?(d[i].innerHTML="",d[i].hidePopover()):Object.values(d).forEach(t=>{t.innerHTML="",t.hidePopover()})}var m=window.ot||(window.ot={});m.toast=b;m.toast.el=g;m.toast.clear=v;})(); diff --git a/static/td.css b/static/td.css new file mode 100644 index 0000000000000000000000000000000000000000..5af1462f0836c656e1212c30b005be35c9ce11a8 --- /dev/null +++ b/static/td.css @@ -0,0 +1,51 @@ +@font-face { + font-family: "Atkinson Hyperlegible Next"; + src: url("/static/fonts/AtkinsonHyperlegibleNext-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Atkinson Hyperlegible Next"; + src: url("/static/fonts/AtkinsonHyperlegibleNext-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "0xProto"; + src: url("/static/fonts/0xProto-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +:root { + --font-sans: "Atkinson Hyperlegible Next", system-ui, sans-serif; + --font-mono: "0xProto", ui-monospace, monospace; +} +body { font-family: var(--font-sans); } +code, pre, .mono { font-family: var(--font-mono); } +.skip-link { + position: absolute; + left: -9999px; + z-index: 999; + padding: 1em; + background: #000; + color: #fff; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.skip-link:focus { + left: 50%; + transform: translateX(-50%); + top: 0; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000000000000000000000000000000000000..7985b0448559888ebef3452dce99d82c4547d708 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,38 @@ + + + + + + {% block title %}td{% endblock %} + + + + + + + + + + + +
+
+ {% block content %}{% endblock %} +
+
+ + diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000000000000000000000000000000000000..b2ac79dad2043609d77ab93445474e2d276b1265 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block title %}td — error{% endblock %} + +{% block content %} +
+ {{ status_code }} — {{ message }} +
+

← Back to projects

+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..d27c8fdc5986e621888a51445da3f5215b2943fc --- /dev/null +++ b/templates/index.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}td — Projects{% endblock %} + +{% block content %} +

Projects

+ +{% if projects.is_empty() %} +
+

No projects yet

+

Run td project init <name> to create one.

+
+{% else %} +
+
+ {% for p in projects %} +
+ {% match p %} + {% when ProjectCard::Ok { name, open, in_progress, closed, total } %} +
+

{{ name }}

+
+
+ {{ open }} open + {{ in_progress }} in progress + {{ closed }} closed +
+ {% if *total > 0 %} + + {% endif %} + {% when ProjectCard::Err { name, error } %} +
+

{{ name }}

+ error +
+

{{ error }}

+ {% endmatch %} +
+ {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/templates/project.html b/templates/project.html new file mode 100644 index 0000000000000000000000000000000000000000..a42716d2ad70b95713380b6af7696682ee15df29 --- /dev/null +++ b/templates/project.html @@ -0,0 +1,163 @@ +{% extends "base.html" %} + +{% block title %}td — {{ project_name }}{% endblock %} + +{% block content %} + + +

{{ project_name }}

+ +
+
+
+

Open

+

{{ stats_open }}

+
+
+

In progress

+

{{ stats_in_progress }}

+
+
+

Closed

+

{{ stats_closed }}

+
+
+
+ +{% if !next_up.is_empty() %} +
+

Next up

+
+ + + + + + + + + + + + {% for (i, s) in next_up.iter().enumerate() %} + + + + + + + {% endfor %} + +
Top scored tasks recommended to work on next
#IDScoreTitle
{{ i + 1 }}{{ s.short_id }}{{ s.score }}{{ s.title }}
+
+
+{% endif %} + +
+

Tasks

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + {% if page_tasks.is_empty() %} +

No tasks match the current filters.

+ {% else %} +
+ + + + + + + + + + + + + {% for t in page_tasks %} + + + + + + + + {% endfor %} + +
Task list for project {{ project_name }}
IDStatusPriorityEffortTitle
{{ t.short_id }}{{ t.status }}{{ t.priority }}{{ t.effort }}{{ t.title }}
+
+ + {% if total_pages > 1 %} + + {% endif %} + {% endif %} +
+{% endblock %} diff --git a/templates/task.html b/templates/task.html new file mode 100644 index 0000000000000000000000000000000000000000..3d54652bac1f2a11c6e0172384354116814c4767 --- /dev/null +++ b/templates/task.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} + +{% block title %}td — {{ task.title }}{% endblock %} + +{% block content %} + + +
+
+

{{ task.title }}

+ {{ task.status }} +
+ + {% if !task.description.is_empty() %} +

{{ task.description }}

+ {% endif %} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Task metadata
ID{{ task.short_id }}
Type{{ task.task_type }}
Priority{{ task.priority }}
Effort{{ task.effort }}
Created
Updated
+
+ + {% if !task.labels.is_empty() %} +
+

Labels

+
+ {% for l in task.labels %} + {{ l }} + {% endfor %} +
+
+ {% endif %} + + {% if !blockers_open.is_empty() || !blockers_resolved.is_empty() %} +
+

Blockers

+ +
+ {% endif %} + + {% if !subtasks.is_empty() %} +
+

Subtasks

+
+ + + + + + + + + + + {% for s in subtasks %} + + + + + + {% endfor %} + +
Subtasks of {{ task.title }}
IDStatusTitle
{{ s.short_id }}{{ s.status }}{{ s.title }}
+
+
+ {% endif %} + + {% if !task.logs.is_empty() %} +
+

Work log

+ {% for log in task.logs %} +
+ +

{{ log.message }}

+
+ {% endfor %} +
+ {% endif %} +
+{% endblock %}