| 1 | //! HTML, built with maud, which escapes every interpolated value. |
| 2 | //! |
| 3 | //! The markup follows `shared/reference.html`. The extra controls a page |
| 4 | //! needs without JavaScript (`.nojs`) are hidden unless `<noscript>` shows |
| 5 | //! them, laid over the parts of the reference design they stand in for. |
| 6 | |
| 7 | use maud::{DOCTYPE, Markup, PreEscaped, html}; |
| 8 | |
| 9 | use crate::db::Todo; |
| 10 | |
| 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 12 | pub enum Filter { |
| 13 | All, |
| 14 | Active, |
| 15 | Completed, |
| 16 | } |
| 17 | |
| 18 | impl Filter { |
| 19 | /// The `filter` form field. Anything unrecognised means all. |
| 20 | pub fn from_field(s: Option<&str>) -> Filter { |
| 21 | match s { |
| 22 | Some("active") => Filter::Active, |
| 23 | Some("completed") => Filter::Completed, |
| 24 | _ => Filter::All, |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | pub fn field(self) -> &'static str { |
| 29 | match self { |
| 30 | Filter::All => "all", |
| 31 | Filter::Active => "active", |
| 32 | Filter::Completed => "completed", |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | pub fn path(self) -> &'static str { |
| 37 | match self { |
| 38 | Filter::All => "/", |
| 39 | Filter::Active => "/active", |
| 40 | Filter::Completed => "/completed", |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | fn shows(self, todo: &Todo) -> bool { |
| 45 | match self { |
| 46 | Filter::All => true, |
| 47 | Filter::Active => !todo.completed, |
| 48 | Filter::Completed => todo.completed, |
| 49 | } |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | pub struct Page<'a> { |
| 54 | pub todos: &'a [Todo], |
| 55 | pub filter: Filter, |
| 56 | pub editing: Option<i64>, |
| 57 | pub language: &'a str, |
| 58 | } |
| 59 | |
| 60 | const NOJS_CSS: &str = "\ |
| 61 | .nojs{display:none}\ |
| 62 | .main>form,.footer>form{display:contents}"; |
| 63 | |
| 64 | const NOSCRIPT_CSS: &str = "\ |
| 65 | .nojs{display:block}\ |
| 66 | .main .toggle-all-submit{position:absolute;top:-65px;left:0;width:45px;height:65px;font-size:0;cursor:pointer}\ |
| 67 | .todo-list li .toggle-submit{position:absolute;top:0;bottom:0;left:0;width:40px;font-size:0;cursor:pointer}\ |
| 68 | .todo-list li .edit-link{position:absolute;top:0;bottom:0;left:60px;right:60px;font-size:0}"; |
| 69 | |
| 70 | pub fn page(p: &Page) -> Markup { |
| 71 | html! { |
| 72 | (DOCTYPE) |
| 73 | html lang="en" { |
| 74 | head { |
| 75 | meta charset="utf-8"; |
| 76 | meta name="viewport" content="width=device-width, initial-scale=1"; |
| 77 | title { "TodoMVC" } |
| 78 | link rel="stylesheet" href="/static/base.css"; |
| 79 | link rel="stylesheet" href="/static/index.css"; |
| 80 | style { (PreEscaped(NOJS_CSS)) } |
| 81 | noscript { style { (PreEscaped(NOSCRIPT_CSS)) } } |
| 82 | script type="module" src="/static/app.js" {} |
| 83 | } |
| 84 | body { |
| 85 | section.todoapp { |
| 86 | header.header { |
| 87 | h1 { "todos" } |
| 88 | form method="post" action="/todos" { |
| 89 | input type="hidden" name="filter" value=(p.filter.field()); |
| 90 | input.new-todo name="title" placeholder="What needs to be done?" |
| 91 | autocomplete="off" autofocus[p.editing.is_none()]; |
| 92 | } |
| 93 | } |
| 94 | (list(p)) |
| 95 | } |
| 96 | footer.info { |
| 97 | p { "Double-click to edit a todo" } |
| 98 | p { |
| 99 | "Created by " a href="https://bakeoff.bfj.oncarlos.com" { "the CARLOS bakeoff" } |
| 100 | " · " (p.language) |
| 101 | } |
| 102 | p { "Part of " a href="http://todomvc.com" { "TodoMVC" } } |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// `section.main` and `footer.footer`: everything that changes after an |
| 110 | /// action. JavaScript swaps this fragment in place. |
| 111 | pub fn list(p: &Page) -> Markup { |
| 112 | let total = p.todos.len(); |
| 113 | let active = p.todos.iter().filter(|t| !t.completed).count(); |
| 114 | let completed = total - active; |
| 115 | let filter = p.filter.field(); |
| 116 | html! { |
| 117 | section.main hidden[total == 0] { |
| 118 | input #toggle-all.toggle-all type="checkbox" checked[total > 0 && active == 0]; |
| 119 | label for="toggle-all" { "Mark all as complete" } |
| 120 | form method="post" action="/todos/toggle-all" { |
| 121 | input type="hidden" name="filter" value=(filter); |
| 122 | button.nojs.toggle-all-submit data-action="toggle-all" { "Mark all as complete" } |
| 123 | } |
| 124 | ul.todo-list { |
| 125 | @for todo in p.todos.iter().filter(|t| p.filter.shows(t)) { |
| 126 | (item(todo, p.filter, p.editing == Some(todo.id))) |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | footer.footer hidden[total == 0] { |
| 131 | span.todo-count { |
| 132 | strong { (active) } (if active == 1 { " item left" } else { " items left" }) |
| 133 | } |
| 134 | ul.filters { |
| 135 | (filter_link(Filter::All, "All", p.filter)) " " |
| 136 | (filter_link(Filter::Active, "Active", p.filter)) " " |
| 137 | (filter_link(Filter::Completed, "Completed", p.filter)) |
| 138 | } |
| 139 | form method="post" action="/todos/clear-completed" { |
| 140 | input type="hidden" name="filter" value=(filter); |
| 141 | button.clear-completed hidden[completed == 0] { "Clear completed" } |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | fn filter_link(f: Filter, text: &str, current: Filter) -> Markup { |
| 148 | html! { li { a class=[(f == current).then_some("selected")] href=(f.path()) { (text) } } } |
| 149 | } |
| 150 | |
| 151 | fn item(todo: &Todo, filter: Filter, editing: bool) -> Markup { |
| 152 | let id = todo.id; |
| 153 | let field = filter.field(); |
| 154 | let class = match (todo.completed, editing) { |
| 155 | (false, false) => None, |
| 156 | (true, false) => Some("completed"), |
| 157 | (false, true) => Some("editing"), |
| 158 | (true, true) => Some("completed editing"), |
| 159 | }; |
| 160 | html! { |
| 161 | li class=[class] data-id=(id) { |
| 162 | div.view { |
| 163 | input.toggle type="checkbox" checked[todo.completed]; |
| 164 | label { (todo.title) } |
| 165 | form method="post" action={ "/todos/" (id) "/toggle" } { |
| 166 | input type="hidden" name="filter" value=(field); |
| 167 | button.nojs.toggle-submit data-action="toggle" { |
| 168 | (if todo.completed { "Mark as active" } else { "Mark as complete" }) |
| 169 | } |
| 170 | } |
| 171 | a.nojs.edit-link data-action="edit" href={ (filter.path()) "?edit=" (id) } { "Edit" } |
| 172 | form method="post" action={ "/todos/" (id) "/destroy" } { |
| 173 | input type="hidden" name="filter" value=(field); |
| 174 | button.destroy aria-label="Delete" {} |
| 175 | } |
| 176 | } |
| 177 | form method="post" action={ "/todos/" (id) "/edit" } { |
| 178 | input type="hidden" name="filter" value=(field); |
| 179 | input.edit name="title" value=(todo.title) autocomplete="off" autofocus[editing]; |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | |