max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
5 files changed,
+1023 insertions,
-823 deletions
| @@ -34,8 +34,8 @@ | |||
| 34 | 34 | //! field. The hidden inputs existed because these were vanilla `<form>`s. | |
| 35 | 35 | ||
| 36 | 36 | use makeover_layout as layout; | |
| 37 | - | use quasi_router::screen::{Cell, Cells, Column, Field, Lexeme, Row, Table}; | |
| 38 | - | use quasi_router::{Act, Action, Document, Node, RegionKind, Run, Screen, Slot}; | |
| 37 | + | use quasi_declare::declare; | |
| 38 | + | use quasi_router::{Action, Document, Node, RegionKind}; | |
| 39 | 39 | use quasi_webview::Webview; | |
| 40 | 40 | ||
| 41 | 41 | use crate::git::signing::SignatureStatus; | |
| @@ -102,314 +102,319 @@ | |||
| 102 | 102 | } | |
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | - | /// The whole document: the title, the measure, the body. | |
| 106 | - | #[must_use] | |
| 107 | - | pub fn screen(view: &View<'_>) -> Screen { | |
| 108 | - | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane) | |
| 109 | - | .with(super::widgets::git_nav::heading(view.owner, view.repo)) | |
| 110 | - | .with(super::widgets::git_nav::region(&view.nav())) | |
| 111 | - | .with(detail(view)); | |
| 105 | + | declare! { | |
| 106 | + | /// The whole document: the title, the measure, the body. | |
| 107 | + | /// | |
| 108 | + | /// Four of its members are `-> Option<_>` shapes, and an `Option` is an | |
| 109 | + | /// iterator of at most one: `.into_iter()` is the method step that says so | |
| 110 | + | /// rather than a production for placing an absence. | |
| 111 | + | #[must_use] | |
| 112 | + | pub shape screen(view: &View<'_>) -> Screen; | |
| 112 | 113 | ||
| 113 | - | if let Some(notes) = super::widgets::git_notes::region(view.notes) { | |
| 114 | - | page = page.with(notes); | |
| 115 | - | } | |
| 116 | - | if let Some(edit) = notes_edit(view) { | |
| 117 | - | page = page.with(edit); | |
| 118 | - | } | |
| 119 | - | if let Some(mine) = annotations(view) { | |
| 120 | - | page = page.with(mine); | |
| 121 | - | } | |
| 122 | - | if let Some(edit) = annotation_edit(view) { | |
| 123 | - | page = page.with(edit); | |
| 124 | - | } | |
| 114 | + | screen single "{view.detail.short_oid} {view.detail.summary} - {view.repo} - Git - Makenotwork" { | |
| 115 | + | measured MEASURE; | |
| 116 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 125 | 117 | ||
| 126 | - | page = page.with(stats(view)); | |
| 127 | - | for file in view.diff_files { | |
| 128 | - | page = page.with(diff_file(view, file)); | |
| 129 | - | } | |
| 118 | + | region PAGE_REGION as Pane { | |
| 119 | + | include super::widgets::git_nav::heading(view.owner, view.repo); | |
| 120 | + | include super::widgets::git_nav::region(&view.nav()); | |
| 121 | + | include detail(view); | |
| 130 | 122 | ||
| 131 | - | Screen::single(format!( | |
| 132 | - | "{} {} - {} - Git - Makenotwork", | |
| 133 | - | view.detail.short_oid, view.detail.summary, view.repo | |
| 123 | + | for notes in super::widgets::git_notes::region(view.notes).into_iter() { | |
| 124 | + | include notes; | |
| 125 | + | } | |
| 126 | + | for edit in notes_edit(view).into_iter() { | |
| 127 | + | include edit; | |
| 128 | + | } | |
| 129 | + | for mine in annotations(view).into_iter() { | |
| 130 | + | include mine; | |
| 131 | + | } | |
| 132 | + | for edit in annotation_edit(view).into_iter() { | |
| 133 | + | include edit; | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | include stats(view); | |
| 137 | + | for file in view.diff_files.iter() { | |
| 138 | + | include diff_file(view, file); | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// Whether the person who committed is not the person who wrote it. | |
| 145 | + | /// | |
| 146 | + | /// Said only when it differs, which is the common case's silence: the same | |
| 147 | + | /// person authored and committed nearly every commit anybody reads. | |
| 148 | + | fn committer_differs(detail: &CommitDetail) -> bool { | |
| 149 | + | detail.committer_name != detail.author_name || detail.committer_email != detail.author_email | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// `Parent:` or `Parents:`, which is a fact about how many there are. | |
| 153 | + | fn parents_label(detail: &CommitDetail) -> &'static str { | |
| 154 | + | if detail.parents.len() == 1 { | |
| 155 | + | "Parent:" | |
| 156 | + | } else { | |
| 157 | + | "Parents:" | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | declare! { | |
| 162 | + | /// The message, its trailers, and who made it when. | |
| 163 | + | /// | |
| 164 | + | /// Trailers are structured metadata that happens to be stored as the last | |
| 165 | + | /// paragraph of the message. Said as rows rather than left in the prose, | |
| 166 | + | /// which is how a Co-authored-by line stops being the last line of a | |
| 167 | + | /// sentence and starts being a second author. | |
| 168 | + | shape detail(view: &View<'_>) -> Node; | |
| 169 | + | ||
| 170 | + | region "git-commit-detail" as Group { | |
| 171 | + | text view.detail.message_body.clone(); | |
| 172 | + | ||
| 173 | + | list { | |
| 174 | + | for trailer in view.detail.trailers.iter() { | |
| 175 | + | row trailer.token.clone() { | |
| 176 | + | meta trailer.value.clone(); | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } unless view.detail.trailers.is_empty(); | |
| 180 | + | ||
| 181 | + | region "git-commit-detail-meta" as Group { | |
| 182 | + | text "Author: {view.detail.author_name} <{view.detail.author_email}> - \ | |
| 183 | + | {view.detail.author_time}"; | |
| 184 | + | text "Committer: {view.detail.committer_name} <{view.detail.committer_email}> - \ | |
| 185 | + | {view.detail.committer_time}" | |
| 186 | + | when committer_differs(view.detail); | |
| 187 | + | ||
| 188 | + | include signature(view) unless signature_kind(view.signature) is "unsigned"; | |
| 189 | + | ||
| 190 | + | // The full id, which is machine text: it is copied into a command | |
| 191 | + | // far more often than it is read. | |
| 192 | + | region "git-commit-oid" as Group { | |
| 193 | + | across Wrap { | |
| 194 | + | beside Secondary text "Commit:"; | |
| 195 | + | beside Essential literal view.detail.oid.clone(); | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | region "git-commit-parents" as Group unless view.detail.parents.is_empty() { | |
| 200 | + | across Wrap { | |
| 201 | + | beside Secondary text parents_label(view.detail); | |
| 202 | + | for parent in view.detail.parents.iter() { | |
| 203 | + | beside Essential link parent.short_oid.clone() | |
| 204 | + | to get "{view.base()}/commit/{parent.oid}" navigating; | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | /// Which of the three things a signature has to say this one says. | |
| 213 | + | /// | |
| 214 | + | /// A supplier because the enum's two carrying variants need a binding pattern | |
| 215 | + | /// and the form has none. It hands back a `&'static str`, which keeps it out of | |
| 216 | + | /// the population, and the three answers are what the description dispatches | |
| 217 | + | /// on. | |
| 218 | + | fn signature_kind(status: &SignatureStatus) -> &'static str { | |
| 219 | + | match status { | |
| 220 | + | SignatureStatus::Unsigned => "unsigned", | |
| 221 | + | SignatureStatus::SignedBy { .. } => "signed_by", | |
| 222 | + | SignatureStatus::ValidUnknownKey { .. } | |
| 223 | + | | SignatureStatus::Invalid | |
| 224 | + | | SignatureStatus::Unverified { .. } => "note", | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// Who signed it, when this server knows them. | |
| 229 | + | fn signer(status: &SignatureStatus) -> &str { | |
| 230 | + | match status { | |
| 231 | + | SignatureStatus::SignedBy { username, .. } => username, | |
| 232 | + | _ => "", | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | /// The key that did it. R9: read whether or not the region is placed. | |
| 237 | + | fn signer_key(status: &SignatureStatus) -> &str { | |
| 238 | + | match status { | |
| 239 | + | SignatureStatus::SignedBy { fingerprint, .. } => fingerprint, | |
| 240 | + | _ => "", | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// What the other three states say in a line. | |
| 245 | + | fn signature_note(status: &SignatureStatus) -> String { | |
| 246 | + | match status { | |
| 247 | + | SignatureStatus::ValidUnknownKey { .. } => "Signed, key not registered here".to_owned(), | |
| 248 | + | SignatureStatus::Invalid => "Signature does not verify".to_owned(), | |
| 249 | + | SignatureStatus::Unverified { format } => format!("Signed with {format}, not checked"), | |
| 250 | + | _ => String::new(), | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// How that line is toned. Only one of the states deserves to look like a | |
| 255 | + | /// problem. | |
| 256 | + | fn signature_tone(status: &SignatureStatus) -> layout::Tone { | |
| 257 | + | match status { | |
| 258 | + | SignatureStatus::Invalid => layout::Tone::Danger, | |
| 259 | + | _ => layout::Tone::Neutral, | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | declare! { | |
| 264 | + | /// What the signature says, when there is one. | |
| 265 | + | /// | |
| 266 | + | /// Nothing renders for an unsigned object, which is `git_signature.html`'s | |
| 267 | + | /// ruling and stands: every forge that badges "unsigned" is training people | |
| 268 | + | /// to ignore the badge, and the overwhelming majority of commits everywhere | |
| 269 | + | /// are unsigned. [`detail`] is where that guard lives, because R2 makes | |
| 270 | + | /// this shape one emission and "nothing" is not one. | |
| 271 | + | /// | |
| 272 | + | /// The verified case names the signer, which is the thing no forge without | |
| 273 | + | /// its own key store can do: the key that made the signature is the key | |
| 274 | + | /// that authenticates their pushes. | |
| 275 | + | shape signature(view: &View<'_>) -> Node; | |
| 276 | + | ||
| 277 | + | given signature_kind(view.signature) { | |
| 278 | + | "signed_by" -> region "git-signature" as Group { | |
| 279 | + | named "Signed by {signer(view.signature)}, key {signer_key(view.signature)}"; | |
| 280 | + | across Wrap { | |
| 281 | + | beside Essential text "Signed by"; | |
| 282 | + | beside Essential link signer(view.signature) | |
| 283 | + | to get "/u/{signer(view.signature)}" navigating; | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | otherwise -> toned signature_note(view.signature) signature_tone(view.signature); | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /// The notes on this commit the write routes will accept a save to. | |
| 291 | + | /// | |
| 292 | + | /// A supplier because `filter` takes a closure. It hands back borrowed notes, | |
| 293 | + | /// which is not a vocabulary type and so is not counted. | |
| 294 | + | fn writable(notes: &[CommitNote]) -> Vec<&CommitNote> { | |
| 295 | + | notes.iter().filter(|note| !note.read_only).collect() | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | /// Where a note's delete goes, with the namespace along for the ride. | |
| 299 | + | /// | |
| 300 | + | /// A supplier because `Action::with(name, value)` is refused as a production: a | |
| 301 | + | /// form has no way to say "and this value too, sometimes", and `doing` is the | |
| 302 | + | /// remedy the form already names. Counted, for the reason `tip::checkout` is. | |
| 303 | + | fn note_delete(view: &View<'_>, namespace: &str) -> Action { | |
| 304 | + | Action::post(format!( | |
| 305 | + | "{}/commit/{}/notes/delete", | |
| 306 | + | view.base(), | |
| 307 | + | view.detail.oid | |
| 134 | 308 | )) | |
| 135 | - | .measured(MEASURE) | |
| 136 | - | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 137 | - | .with(page) | |
| 309 | + | .with("namespace", namespace.to_owned()) | |
| 138 | 310 | } | |
| 139 | 311 | ||
| 140 | - | /// The message, its trailers, and who made it when. | |
| 141 | - | fn detail(view: &View<'_>) -> Node { | |
| 142 | - | let detail = view.detail; | |
| 143 | - | let mut block = Slot::new("git-commit-detail", RegionKind::Group) | |
| 144 | - | .with(Node::text(detail.message_body.clone())); | |
| 312 | + | declare! { | |
| 313 | + | /// The write half for the repository's own notes. | |
| 314 | + | /// | |
| 315 | + | /// A note in a namespace the write routes refuse gets no form: | |
| 316 | + | /// makenot.work writes `refs/notes/mnw/*`, and offering an edit box that | |
| 317 | + | /// saves to a 422 is worse than offering nothing. | |
| 318 | + | shape notes_edit(view: &View<'_>) -> Option<Node>; | |
| 145 | 319 | ||
| 146 | - | // Trailers are structured metadata that happens to be stored as the last | |
| 147 | - | // paragraph of the message. Said as rows rather than left in the prose, | |
| 148 | - | // which is how a Co-authored-by line stops being the last line of a | |
| 149 | - | // sentence and starts being a second author. | |
| 150 | - | if !detail.trailers.is_empty() { | |
| 151 | - | block = block.with(Node::list( | |
| 152 | - | detail | |
| 153 | - | .trailers | |
| 154 | - | .iter() | |
| 155 | - | .map(|t| Row::new(t.token.clone()).meta(t.value.clone())), | |
| 156 | - | )); | |
| 157 | - | } | |
| 320 | + | region "git-notes-edit" as Group when view.can_write_notes { | |
| 321 | + | toned "Somebody edited this note while you were writing. Both versions were kept, \ | |
| 322 | + | so what is below is not exactly what you typed." | |
| 323 | + | layout::Tone::Warning | |
| 324 | + | when view.notes_merged; | |
| 158 | 325 | ||
| 159 | - | let mut meta = | |
| 160 | - | Slot::new("git-commit-detail-meta", RegionKind::Group).with(Node::text(format!( | |
| 161 | - | "Author: {} <{}> - {}", | |
| 162 | - | detail.author_name, detail.author_email, detail.author_time | |
| 163 | - | ))); | |
| 164 | - | ||
| 165 | - | // Said only when it differs, which is the common case's silence: the same | |
| 166 | - | // person authored and committed nearly every commit anybody reads. | |
| 167 | - | if detail.committer_name != detail.author_name || detail.committer_email != detail.author_email | |
| 168 | - | { | |
| 169 | - | meta = meta.with(Node::text(format!( | |
| 170 | - | "Committer: {} <{}> - {}", | |
| 171 | - | detail.committer_name, detail.committer_email, detail.committer_time | |
| 172 | - | ))); | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | if let Some(signature) = signature(view) { | |
| 176 | - | meta = meta.with(signature); | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // The full id, which is machine text: it is copied into a command far more | |
| 180 | - | // often than it is read. | |
| 181 | - | meta = meta.with(Node::Region( | |
| 182 | - | Slot::new("git-commit-oid", RegionKind::Group).across( | |
| 183 | - | Run::new(layout::Fallback::Wrap) | |
| 184 | - | .beside(Node::text("Commit:"), layout::Priority::Secondary) | |
| 185 | - | .beside(literal(&detail.oid), layout::Priority::Essential), | |
| 186 | - | ), | |
| 187 | - | )); | |
| 188 | - | ||
| 189 | - | if !detail.parents.is_empty() { | |
| 190 | - | let mut parents = Run::new(layout::Fallback::Wrap).beside( | |
| 191 | - | Node::text(if detail.parents.len() == 1 { | |
| 192 | - | "Parent:" | |
| 193 | - | } else { | |
| 194 | - | "Parents:" | |
| 195 | - | }), | |
| 196 | - | layout::Priority::Secondary, | |
| 197 | - | ); | |
| 198 | - | for parent in &detail.parents { | |
| 199 | - | parents = parents.beside( | |
| 200 | - | Node::Link { | |
| 201 | - | text: parent.short_oid.clone(), | |
| 202 | - | action: Action::get(format!("{}/commit/{}", view.base(), parent.oid)) | |
| 203 | - | .navigating(), | |
| 204 | - | }, | |
| 205 | - | layout::Priority::Essential, | |
| 206 | - | ); | |
| 326 | + | for note in writable(view.notes) { | |
| 327 | + | form post "{view.base()}/commit/{view.detail.oid}/notes" { | |
| 328 | + | submit "Save note"; | |
| 329 | + | // Which note is being saved. The reader does not choose it here | |
| 330 | + | // -- they are editing the one in front of them -- so it rides | |
| 331 | + | // along rather than being asked. | |
| 332 | + | field Hidden "namespace" "Namespace of the note being saved: {note.namespace}" { | |
| 333 | + | value note.namespace.clone(); | |
| 334 | + | } | |
| 335 | + | field Textarea "content" "Edit the note in {note.namespace}" { | |
| 336 | + | value note.source.clone(); | |
| 337 | + | limited_to BODY_LIMIT; | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | act "Remove the note in {note.namespace}" to doing note_delete(view, ¬e.namespace) { | |
| 341 | + | tone Danger; | |
| 342 | + | } | |
| 207 | 343 | } | |
| 208 | - | meta = meta.with(Node::Region( | |
| 209 | - | Slot::new("git-commit-parents", RegionKind::Group).across(parents), | |
| 210 | - | )); | |
| 211 | - | } | |
| 212 | 344 | ||
| 213 | - | Node::Region(block.with(Node::Region(meta))) | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | /// What the signature says, when there is one. | |
| 217 | - | /// | |
| 218 | - | /// Nothing renders for an unsigned object, which is `git_signature.html`'s | |
| 219 | - | /// ruling and stands: every forge that badges "unsigned" is training people to | |
| 220 | - | /// ignore the badge, and the overwhelming majority of commits everywhere are | |
| 221 | - | /// unsigned. | |
| 222 | - | /// | |
| 223 | - | /// The verified case names the signer, which is the thing no forge without its | |
| 224 | - | /// own key store can do: the key that made the signature is the key that | |
| 225 | - | /// authenticates their pushes. | |
| 226 | - | fn signature(view: &View<'_>) -> Option<Node> { | |
| 227 | - | let plain = |text: &str, tone: layout::Tone| { | |
| 228 | - | Some(Node::Text { | |
| 229 | - | text: text.to_owned(), | |
| 230 | - | tone, | |
| 231 | - | }) | |
| 232 | - | }; | |
| 233 | - | ||
| 234 | - | match view.signature { | |
| 235 | - | SignatureStatus::Unsigned => None, | |
| 236 | - | SignatureStatus::SignedBy { | |
| 237 | - | username, | |
| 238 | - | fingerprint, | |
| 239 | - | } => Some(Node::Region( | |
| 240 | - | Slot::new("git-signature", RegionKind::Group) | |
| 241 | - | .named(format!("Signed by {username}, key {fingerprint}")) | |
| 242 | - | .across( | |
| 243 | - | Run::new(layout::Fallback::Wrap) | |
| 244 | - | .beside(Node::text("Signed by"), layout::Priority::Essential) | |
| 245 | - | .beside( | |
| 246 | - | Node::Link { | |
| 247 | - | text: username.clone(), | |
| 248 | - | action: Action::get(format!("/u/{username}")).navigating(), | |
| 249 | - | }, | |
| 250 | - | layout::Priority::Essential, | |
| 251 | - | ), | |
| 252 | - | ), | |
| 253 | - | )), | |
| 254 | - | SignatureStatus::ValidUnknownKey { .. } => { | |
| 255 | - | plain("Signed, key not registered here", layout::Tone::Neutral) | |
| 256 | - | } | |
| 257 | - | // The only state that deserves to look like a problem. | |
| 258 | - | SignatureStatus::Invalid => plain("Signature does not verify", layout::Tone::Danger), | |
| 259 | - | SignatureStatus::Unverified { format } => plain( | |
| 260 | - | &format!("Signed with {format}, not checked"), | |
| 261 | - | layout::Tone::Neutral, | |
| 262 | - | ), | |
| 263 | - | } | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | /// The write half for the repository's own notes. | |
| 267 | - | /// | |
| 268 | - | /// A note in a namespace the write routes refuse gets no form: makenot.work | |
| 269 | - | /// writes `refs/notes/mnw/*`, and offering an edit box that saves to a 422 is | |
| 270 | - | /// worse than offering nothing. | |
| 271 | - | fn notes_edit(view: &View<'_>) -> Option<Node> { | |
| 272 | - | if !view.can_write_notes { | |
| 273 | - | return None; | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | let post = format!("{}/commit/{}/notes", view.base(), view.detail.oid); | |
| 277 | - | let mut block = Slot::new("git-notes-edit", RegionKind::Group); | |
| 278 | - | ||
| 279 | - | if view.notes_merged { | |
| 280 | - | block = block.with(Node::Text { | |
| 281 | - | text: "Somebody edited this note while you were writing. Both versions were kept, \ | |
| 282 | - | so what is below is not exactly what you typed." | |
| 283 | - | .to_owned(), | |
| 284 | - | tone: layout::Tone::Warning, | |
| 285 | - | }); | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | for note in view.notes.iter().filter(|n| !n.read_only) { | |
| 289 | - | block = block | |
| 290 | - | .with(Node::Form { | |
| 291 | - | action: Action::post(post.clone()), | |
| 292 | - | submit: "Save note".to_owned(), | |
| 293 | - | fields: vec![ | |
| 294 | - | // Which note is being saved. The reader does not choose it | |
| 295 | - | // here -- they are editing the one in front of them -- so | |
| 296 | - | // it rides along rather than being asked. | |
| 297 | - | Field::new( | |
| 298 | - | layout::FieldKind::Hidden, | |
| 299 | - | "namespace", | |
| 300 | - | format!("Namespace of the note being saved: {}", note.namespace), | |
| 301 | - | ) | |
| 302 | - | .value(note.namespace.clone()), | |
| 303 | - | body_field( | |
| 304 | - | format!("Edit the note in {}", note.namespace), | |
| 305 | - | ¬e.source, | |
| 306 | - | None, | |
| 307 | - | ), | |
| 308 | - | ], | |
| 309 | - | }) | |
| 310 | - | .with(Node::Act( | |
| 311 | - | Act::new( | |
| 312 | - | format!("Remove the note in {}", note.namespace), | |
| 313 | - | Action::post(format!("{post}/delete")) | |
| 314 | - | .with("namespace", note.namespace.clone()), | |
| 315 | - | ) | |
| 316 | - | .tone(layout::Tone::Danger), | |
| 317 | - | )); | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | Some(Node::Region(block.with(Node::Form { | |
| 321 | - | action: Action::post(post), | |
| 322 | - | submit: "Add note".to_owned(), | |
| 323 | - | fields: vec![ | |
| 345 | + | form post "{view.base()}/commit/{view.detail.oid}/notes" { | |
| 346 | + | submit "Add note"; | |
| 324 | 347 | // `commits` is what `git notes` writes with no namespace given, so | |
| 325 | 348 | // a reader running stock git finds a note left at the default. | |
| 326 | - | Field::new(layout::FieldKind::Text, "namespace", "Namespace") | |
| 327 | - | .value("commits") | |
| 328 | - | .required(), | |
| 329 | - | body_field( | |
| 330 | - | "Add a note", | |
| 331 | - | "", | |
| 332 | - | Some( | |
| 333 | - | "Markdown. Stored in the repository under refs/notes, and it travels \ | |
| 334 | - | with a clone that fetches them.", | |
| 335 | - | ), | |
| 336 | - | ), | |
| 337 | - | ], | |
| 338 | - | }))) | |
| 349 | + | field Text "namespace" "Namespace" { | |
| 350 | + | value "commits"; | |
| 351 | + | required; | |
| 352 | + | } | |
| 353 | + | field Textarea "content" "Add a note" { | |
| 354 | + | limited_to BODY_LIMIT; | |
| 355 | + | placeholder "Markdown. Stored in the repository under refs/notes, and it \ | |
| 356 | + | travels with a clone that fetches them."; | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | } | |
| 339 | 360 | } | |
| 340 | 361 | ||
| 341 | - | /// The reader's own annotations on this commit. | |
| 342 | - | /// | |
| 343 | - | /// Nobody but the signed-in reader ever sees one: the handler reads their | |
| 344 | - | /// annotation repository and nobody else's, and reads nothing at all for a | |
| 345 | - | /// visitor who is signed out. | |
| 346 | - | fn annotations(view: &View<'_>) -> Option<Node> { | |
| 347 | - | if !view.can_annotate { | |
| 348 | - | return None; |
Lines truncated
| @@ -68,10 +68,8 @@ | |||
| 68 | 68 | use std::time::Duration; | |
| 69 | 69 | ||
| 70 | 70 | use makeover_layout as layout; | |
| 71 | - | use quasi_router::{ | |
| 72 | - | Action, Cell, Cells, Choice, Column, Consult, Document, Field, Meter, Node, RegionKind, | |
| 73 | - | Request, Response, RouteError, Row, Screen, Slot, Table, | |
| 74 | - | }; | |
| 71 | + | use quasi_declare::declare; | |
| 72 | + | use quasi_router::{Action, Choice, Consult, Document, Meter, Node, Request, Response, RouteError}; | |
| 75 | 73 | use quasi_webview::Webview; | |
| 76 | 74 | ||
| 77 | 75 | use crate::Billing; | |
| @@ -358,259 +356,277 @@ | |||
| 358 | 356 | )) | |
| 359 | 357 | } | |
| 360 | 358 | ||
| 361 | - | /// The described document, top to bottom. | |
| 362 | - | fn page(state: &Pricing, dials: &Dials) -> Screen { | |
| 363 | - | Screen::list_detail("Pricing Calculator - Makenotwork", false) | |
| 364 | - | .measured(MEASURE) | |
| 365 | - | // What `pricing.html` said as `class="centered-page"`, read off the | |
| 366 | - | // measure declared on the line above rather than off a route table. | |
| 367 | - | // | |
| 368 | - | // On the screen and not on the shell (quasicoherent `ee1882e0`). The | |
| 369 | - | // shell is built once and `Arc`'d at adapter construction, so a class | |
| 370 | - | // set there is a constant for every screen that adapter ever serves -- | |
| 371 | - | // which is right for one page and silently wrong for the second, and | |
| 372 | - | // the second is what the conversion is producing. The empty slice is | |
| 373 | - | // `pricing.html` carrying nothing beside the measure, which is why this | |
| 374 | - | // screen needed no mapping at all. | |
| 375 | - | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 376 | - | // The whole of what `base.html` put in `<meta name="description">`, | |
| 377 | - | // including the fee sentence, because this one string is now all three | |
| 378 | - | // tags: the social pair and the plain one. It carried only the first | |
| 379 | - | // sentence while the plain tag was appended separately. | |
| 380 | - | .summarised( | |
| 381 | - | "Work out what you keep on every sale here, against whatever the \ | |
| 382 | - | platform you sell on now deducts. 0% platform fee, only the \ | |
| 383 | - | payment processor's ~3%.", | |
| 384 | - | ) | |
| 385 | - | .with( | |
| 386 | - | Slot::new(PAGE, RegionKind::Pane) | |
| 387 | - | .with(Node::page("Pricing Calculator")) | |
| 388 | - | .with(Node::text("See what you keep on every sale.")) | |
| 389 | - | .with(Node::Region(calculator(state, dials))) | |
| 390 | - | .with(Node::act("Join the Alpha", Action::get("/join"))) | |
| 391 | - | .with(Node::List { | |
| 392 | - | rows: vec![ | |
| 393 | - | Row::new("Home").activate(Action::get("/")), | |
| 394 | - | Row::new("Browse as guest").activate(Action::get("/discover")), | |
| 395 | - | ], | |
| 396 | - | more: None, | |
| 397 | - | }) | |
| 398 | - | .with(Node::Region(footer(state))), | |
| 399 | - | ) | |
| 359 | + | declare! { | |
| 360 | + | /// The described document, top to bottom. | |
| 361 | + | /// | |
| 362 | + | /// `measured` is what `pricing.html` said as `class="centered-page"`, read | |
| 363 | + | /// off the screen rather than off a route table, and `documented` is that | |
| 364 | + | /// class landing on the body. On the screen and not on the shell | |
| 365 | + | /// (quasicoherent `ee1882e0`): the shell is built once and `Arc`'d at | |
| 366 | + | /// adapter construction, so a class set there is a constant for every | |
| 367 | + | /// screen that adapter ever serves. | |
| 368 | + | /// | |
| 369 | + | /// `summarised` is the whole of what `base.html` put in | |
| 370 | + | /// `<meta name="description">`, including the fee sentence, because this | |
| 371 | + | /// one string is now all three tags: the social pair and the plain one. | |
| 372 | + | shape page(state: &Pricing, dials: &Dials) -> Screen; | |
| 373 | + | ||
| 374 | + | screen list_detail "Pricing Calculator - Makenotwork" false { | |
| 375 | + | measured MEASURE; | |
| 376 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 377 | + | summarised "Work out what you keep on every sale here, against whatever the \ | |
| 378 | + | platform you sell on now deducts. 0% platform fee, only the \ | |
| 379 | + | payment processor's ~3%."; | |
| 380 | + | ||
| 381 | + | region PAGE as Pane { | |
| 382 | + | page "Pricing Calculator"; | |
| 383 | + | text "See what you keep on every sale."; | |
| 384 | + | include calculator(state, dials); | |
| 385 | + | act "Join the Alpha" to get "/join"; | |
| 386 | + | list { | |
| 387 | + | row "Home" { | |
| 388 | + | activate to get "/"; | |
| 389 | + | } | |
| 390 | + | row "Browse as guest" { | |
| 391 | + | activate to get "/discover"; | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | include footer(state); | |
| 395 | + | } | |
| 396 | + | } | |
| 400 | 397 | } | |
| 401 | 398 | ||
| 402 | - | /// Every dial, and the panel they recompute. | |
| 403 | - | fn calculator(state: &Pricing, dials: &Dials) -> Slot { | |
| 404 | - | let prices = &state.billing.tier_prices; | |
| 405 | - | let inputs = dials.inputs; | |
| 399 | + | declare! { | |
| 400 | + | /// Every dial, and the panel they recompute. | |
| 401 | + | /// | |
| 402 | + | /// The region names the route, the wait and the region the answer lands in; | |
| 403 | + | /// the values it sends are the questions it contains, gathered by | |
| 404 | + | /// containment. That is the whole of the shipped `hx-trigger` block -- five | |
| 405 | + | /// `input changed delay:300ms from:#id` clauses and an `hx-include` listing | |
| 406 | + | /// the same five ids again -- said once, with nothing naming a dial. | |
| 407 | + | /// | |
| 408 | + | /// The dials stand in the region rather than in a form, because there is no | |
| 409 | + | /// form: nothing on this page submits, and a consulting region is what asks. | |
| 410 | + | shape calculator(state: &Pricing, dials: &Dials) -> Slot; | |
| 406 | 411 | ||
| 407 | - | let mut slot = Slot::group(CALCULATOR) | |
| 408 | - | .consulting(Consult::new(Action::get(COMPARE).replacing(RESULTS)).after(SETTLES)) | |
| 409 | - | .with(Node::section("What you sell")) | |
| 410 | - | .with(Node::field(dial( | |
| 412 | + | region CALCULATOR as Group { | |
| 413 | + | consulting Consult::new(Action::get(COMPARE).replacing(RESULTS)).after(SETTLES); | |
| 414 | + | ||
| 415 | + | section "What you sell"; | |
| 416 | + | include dial( | |
| 411 | 417 | ITEM_PRICE, | |
| 412 | 418 | "Price per item", | |
| 413 | - | inputs.item_price, | |
| 419 | + | dials.inputs.item_price, | |
| 414 | 420 | fee_calculator::MAX_ITEM_PRICE, | |
| 415 | 421 | "1", | |
| 416 | - | "USD", | |
| 417 | - | ))) | |
| 418 | - | .with(Node::field(dial( | |
| 422 | + | "USD" | |
| 423 | + | ); | |
| 424 | + | include dial( | |
| 419 | 425 | SALES, | |
| 420 | 426 | "Sales per month", | |
| 421 | - | inputs.sales_per_month, | |
| 427 | + | dials.inputs.sales_per_month, | |
| 422 | 428 | fee_calculator::MAX_SALES, | |
| 423 | 429 | "1", | |
| 424 | - | "/mo", | |
| 425 | - | ))); | |
| 430 | + | "/mo" | |
| 431 | + | ); | |
| 426 | 432 | ||
| 427 | - | // Same wording as the landing tagline, because it is the same offer. | |
| 428 | - | if state.founder_window_open { | |
| 429 | - | slot = slot.with(Node::banner( | |
| 430 | - | layout::Tone::Success, | |
| 433 | + | // Same wording as the landing tagline, because it is the same offer. | |
| 434 | + | banner layout::Tone::Success | |
| 431 | 435 | "Founder pricing open. Half off creator tiers, locked for life. The \ | |
| 432 | - | calculator is using founder prices.", | |
| 433 | - | )); | |
| 434 | - | } | |
| 436 | + | calculator is using founder prices." | |
| 437 | + | when state.founder_window_open; | |
| 435 | 438 | ||
| 436 | - | slot = slot | |
| 437 | - | .with(Node::section("Your content tier")) | |
| 438 | - | .with(Node::text( | |
| 439 | - | "Every tier is the complete platform: profile, project pages, forum, \ | |
| 440 | - | discovery, memberships, analytics, full data export. The tier picks \ | |
| 441 | - | the file-size envelope, not the feature set.", | |
| 442 | - | )); | |
| 439 | + | section "Your content tier"; | |
| 440 | + | text "Every tier is the complete platform: profile, project pages, forum, \ | |
| 441 | + | discovery, memberships, analytics, full data export. The tier picks \ | |
| 442 | + | the file-size envelope, not the feature set."; | |
| 443 | 443 | ||
| 444 | - | // Only while there are two rates to choose between. With the window shut | |
| 445 | - | // the page is what it was before either the toggle or this question | |
| 446 | - | // existed. | |
| 447 | - | if state.founder_window_open { | |
| 448 | - | slot = slot.with(Node::field( | |
| 449 | - | Field::radio( | |
| 450 | - | PRICE_MODE, | |
| 451 | - | "Calculate with", | |
| 452 | - | vec![ | |
| 453 | - | Choice::new(FOUNDER, "Founder price"), | |
| 454 | - | Choice::new(LIST, "List price"), | |
| 455 | - | ], | |
| 456 | - | ) | |
| 457 | - | .value(dials.mode), | |
| 458 | - | )); | |
| 459 | - | } | |
| 444 | + | // Only while there are two rates to choose between. With the window shut | |
| 445 | + | // the page is what it was before either the toggle or this question | |
| 446 | + | // existed. | |
| 447 | + | field Radio PRICE_MODE "Calculate with" when state.founder_window_open { | |
| 448 | + | option Choice::new(FOUNDER, "Founder price"); | |
| 449 | + | option Choice::new(LIST, "List price"); | |
| 450 | + | value dials.mode; | |
| 451 | + | } | |
| 460 | 452 | ||
| 461 | - | slot = slot | |
| 462 | - | .with(Node::field( | |
| 463 | - | Field::radio( | |
| 464 | - | TIER, | |
| 465 | - | "Content tier", | |
| 466 | - | Tier::ALL | |
| 467 | - | .iter() | |
| 468 | - | .map(|tier| { | |
| 469 | - | Choice::new(tier.key, tier.label).detailing(tier.detail(prices, dials.mode)) | |
| 470 | - | }) | |
| 471 | - | .collect(), | |
| 472 | - | ) | |
| 473 | - | .value(dials.tier), | |
| 474 | - | )) | |
| 475 | - | .with(Node::section("Wherever else you sell")) | |
| 476 | - | .with(Node::text( | |
| 477 | - | "Fill in what the other platform takes. Use its total deduction, its \ | |
| 478 | - | own cut plus any payment processing it adds, which is the figure you \ | |
| 479 | - | can read off a payout. We hold no rates for anyone but ourselves, so \ | |
| 480 | - | nothing here can go stale or be picked to flatter us.", | |
| 481 | - | )) | |
| 482 | - | .with(Node::field(dial( | |
| 453 | + | field Radio TIER "Content tier" { | |
| 454 | + | for tier in Tier::ALL.iter() { | |
| 455 | + | option Choice::new(tier.key, tier.label) | |
| 456 | + | .detailing(tier.detail(&state.billing.tier_prices, dials.mode)); | |
| 457 | + | } | |
| 458 | + | value dials.tier; | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | section "Wherever else you sell"; | |
| 462 | + | text "Fill in what the other platform takes. Use its total deduction, its \ | |
| 463 | + | own cut plus any payment processing it adds, which is the figure you \ | |
| 464 | + | can read off a payout. We hold no rates for anyone but ourselves, so \ | |
| 465 | + | nothing here can go stale or be picked to flatter us."; | |
| 466 | + | include dial( | |
| 483 | 467 | OTHER_PCT, | |
| 484 | 468 | "Their cut", | |
| 485 | - | inputs.other_pct * 100.0, | |
| 486 | - | fee_calculator::MAX_OTHER_PCT * 100.0, | |
| 469 | + | other_pct_shown(dials), | |
| 470 | + | other_pct_max(), | |
| 487 | 471 | "0.1", | |
| 488 | - | "%", | |
| 489 | - | ))) | |
| 490 | - | .with(Node::field(dial( | |
| 472 | + | "%" | |
| 473 | + | ); | |
| 474 | + | include dial( | |
| 491 | 475 | OTHER_PER_SALE, | |
| 492 | 476 | "Their fee per sale", | |
| 493 | - | inputs.other_per_sale, | |
| 477 | + | dials.inputs.other_per_sale, | |
| 494 | 478 | fee_calculator::MAX_OTHER_PER_SALE, | |
| 495 | 479 | "0.05", | |
| 496 | - | "USD", | |
| 497 | - | ))) | |
| 498 | - | .with(Node::Region(results(&dials.outcome(state)))); | |
| 480 | + | "USD" | |
| 481 | + | ); | |
| 499 | 482 | ||
| 500 | - | slot | |
| 483 | + | include results(&dials.outcome(state)); | |
| 484 | + | } | |
| 501 | 485 | } | |
| 502 | 486 | ||
| 503 | - | /// A dial: a bounded number holding what it holds, in the unit it is measured | |
| 504 | - | /// in. | |
| 487 | + | /// Their cut as the reader typed it. | |
| 505 | 488 | /// | |
| 506 | - | /// `min` and `max` are set on the struct rather than through a builder because | |
| 507 | - | /// `Field::range` is the slider's constructor and these are typed boxes: the | |
| 508 | - | /// bounds are a rule the answer is checked against, not the control itself. | |
| 509 | - | /// They are `fee_calculator`'s own constants, so the box refuses what | |
| 510 | - | /// `FeeCalculator::sanitize` would clamp instead of silently disagreeing with | |
| 511 | - | /// it. | |
| 512 | - | fn dial( | |
| 513 | - | name: &'static str, | |
| 514 | - | label: &'static str, | |
| 515 | - | value: f64, | |
| 516 | - | max: f64, | |
| 517 | - | step: &'static str, | |
| 518 | - | unit: &'static str, | |
| 519 | - | ) -> Field { | |
| 520 | - | let mut field = Field::new(layout::FieldKind::Number, name, label) | |
| 521 | - | .value(fmt_dial(value)) | |
| 522 | - | .step(step) | |
| 523 | - | .unit(unit); | |
| 524 | - | field.min = Some("0".to_string()); | |
| 525 | - | field.max = Some(fmt_dial(max)); | |
| 526 | - | field | |
| 489 | + | /// Held as a fraction and typed as a whole percent, which is the one conversion | |
| 490 | + | /// this page does. A supplier because multiplication is an expression and the | |
| 491 | + | /// form admits none; it hands back an `f64`, which keeps it out of the | |
| 492 | + | /// population. | |
| 493 | + | fn other_pct_shown(dials: &Dials) -> f64 { | |
| 494 | + | dials.inputs.other_pct * 100.0 | |
| 527 | 495 | } | |
| 528 | 496 | ||
| 529 | - | /// The panel the recompute replaces. | |
| 497 | + | /// The same conversion for the bound. See [`other_pct_shown`]. | |
| 498 | + | fn other_pct_max() -> f64 { | |
| 499 | + | fee_calculator::MAX_OTHER_PCT * 100.0 | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | declare! { | |
| 503 | + | /// A dial: a bounded number holding what it holds, in the unit it is | |
| 504 | + | /// measured in. | |
| 505 | + | /// | |
| 506 | + | /// The bounds are `fee_calculator`'s own constants, so the box refuses what | |
| 507 | + | /// `FeeCalculator::sanitize` would clamp instead of silently disagreeing | |
| 508 | + | /// with it. `within` and not `Field::range`: the latter is the slider's | |
| 509 | + | /// constructor and these are typed boxes, so the bounds are a rule the | |
| 510 | + | /// answer is checked against rather than the control itself. | |
| 511 | + | shape dial( | |
| 512 | + | name: &'static str, | |
| 513 | + | label: &'static str, | |
| 514 | + | value: f64, | |
| 515 | + | max: f64, | |
| 516 | + | step: &'static str, | |
| 517 | + | unit: &'static str, | |
| 518 | + | ) -> Field; | |
| 519 | + | ||
| 520 | + | field Number name label { | |
| 521 | + | value fmt_dial(value); | |
| 522 | + | step step; | |
| 523 | + | unit unit; | |
| 524 | + | within "0" fmt_dial(max); | |
| 525 | + | } | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | /// Where the reader's volume sits against the crossover, as a proportion. | |
| 530 | 529 | /// | |
| 531 | - | /// Returns the [`Slot`] rather than a [`Node`] so the screen can nest it and | |
| 532 | - | /// [`compare`] can answer with it, which is the one thing both paths have to | |
| 533 | - | /// agree about. | |
| 534 | - | fn results(outcome: &Outcome) -> Slot { | |
| 535 | - | let mut slot = Slot::new(RESULTS, RegionKind::Pane) | |
| 536 | - | .with(Node::banner(tone(outcome.verdict), &outcome.headline)) | |
| 537 | - | // Three columns and three fixed rows, written in one expression, so | |
| 538 | - | // the cells stay positional: you can read a row against its heading | |
| 539 | - | // without leaving the call, and every row carries the same three cells | |
| 540 | - | // whatever the arithmetic said. Naming them would buy nothing here. | |
| 541 | - | .with(Node::from( | |
| 542 | - | Table::new([ | |
| 543 | - | Column::new("Monthly") | |
| 544 | - | .width(layout::Width::Fill) | |
| 545 | - | .priority(layout::Priority::Essential), | |
| 546 | - | Column::new("Here") | |
| 547 | - | .width(layout::Width::Content) | |
| 548 | - | .priority(layout::Priority::Essential), | |
| 549 | - | Column::new("The other platform") | |
| 550 | - | .width(layout::Width::Content) | |
| 551 | - | .priority(layout::Priority::Essential), | |
| 552 | - | ]) | |
| 553 | - | .rows([ | |
| 554 | - | Cells::new([ | |
| 555 | - | Cell::new("You sell"), | |
| 556 | - | Cell::new(outcome.gross.clone()), | |
| 557 | - | Cell::new(outcome.gross.clone()), | |
| 558 | - | ]), | |
| 559 | - | Cells::new([ | |
| 560 | - | Cell::new("You keep"), | |
| 561 | - | Cell::new(outcome.mnw_keep.clone()), | |
| 562 | - | Cell::new(outcome.other_keep.clone()), | |
| 563 | - | ]), | |
| 564 | - | Cells::new([ | |
| 565 | - | Cell::new("Total fees"), | |
| 566 | - | Cell::new(outcome.mnw_rate.clone()), | |
| 567 | - | Cell::new(outcome.other_rate.clone()), | |
| 568 | - | ]), | |
| 569 | - | ]), | |
| 570 | - | )); | |
| 530 | + | /// A supplier for two reasons and both are recorded rather than worked around. | |
| 531 | + | /// `meter` is a **setting** on `Row` (`quasi_router::Row::meter`) and an ident | |
| 532 | + | /// in a declaration body is a member if it is in the member list and a setting | |
| 533 | + | /// if it is not, so the name cannot mean both -- the fourth instance of the | |
| 534 | + | /// ruling `token` produced, and quasicoherent `e2030032` is where it is being | |
| 535 | + | /// decided. And the two numbers are a rounding and a cast away from the | |
| 536 | + | /// outcome, which is arithmetic the form admits none of. | |
| 537 | + | fn crossover_meter(outcome: &Outcome, at: f64) -> Node { | |
| 538 | + | Node::Meter( | |
| 539 | + | Meter::new( | |
| 540 | + | outcome.sales_per_month.round().max(0.0) as u32, | |
| 541 | + | at.ceil().max(1.0) as u32, | |
| 542 | + | ) | |
| 543 | + | .tone(verdict_tone(outcome.verdict)) | |
| 544 | + | .label("sales a month to the crossover"), | |
| 545 | + | ) | |
| 546 | + | } | |
| 571 | 547 | ||
| 572 | - | // Where the reader's volume sits against the crossover, which is the whole | |
| 573 | - | // of what the hand-drawn two-segment bar said. Absent when there is no | |
| 574 | - | // crossover, because there is then no set to be a proportion of, and the | |
| 575 | - | // note below says why in words. | |
| 576 | - | if let Some(crossover) = outcome.crossover_sales { | |
| 577 | - | slot = slot | |
| 578 | - | .with(Node::section("Where each one wins")) | |
| 579 | - | .with(Node::Meter( | |
| 580 | - | Meter::new( | |
| 581 | - | outcome.sales_per_month.round().max(0.0) as u32, | |
| 582 | - | crossover.ceil().max(1.0) as u32, | |
| 583 | - | ) | |
| 584 | - | .tone(tone(outcome.verdict)) | |
| 585 | - | .label("sales a month to the crossover"), | |
| 586 | - | )); | |
| 548 | + | declare! { | |
| 549 | + | /// The panel the recompute replaces. | |
| 550 | + | /// | |
| 551 | + | /// A `Slot` rather than a `Node` so the screen can nest it and [`compare`] | |
| 552 | + | /// can answer with it, which is the one thing both paths have to agree | |
| 553 | + | /// about. | |
| 554 | + | /// | |
| 555 | + | /// The table is three columns and three fixed rows, so the cells stay | |
| 556 | + | /// positional: a row reads against its heading without leaving the | |
| 557 | + | /// declaration, and every row carries the same three cells whatever the | |
| 558 | + | /// arithmetic said. | |
| 559 | + | shape results(outcome: &Outcome) -> Slot; | |
| 560 | + | ||
| 561 | + | region RESULTS as Pane { | |
| 562 | + | banner verdict_tone(outcome.verdict) &outcome.headline; | |
| 563 | + | ||
| 564 | + | table { | |
| 565 | + | column "Monthly" { | |
| 566 | + | width Fill; | |
| 567 | + | priority Essential; | |
| 568 | + | } | |
| 569 | + | column "Here" { | |
| 570 | + | width Content; | |
| 571 | + | priority Essential; | |
| 572 | + | } | |
| 573 | + | column "The other platform" { | |
| 574 | + | width Content; | |
| 575 | + | priority Essential; | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | cells { | |
| 579 | + | cell "You sell"; | |
| 580 | + | cell outcome.gross.clone(); | |
| 581 | + | cell outcome.gross.clone(); | |
| 582 | + | } | |
| 583 | + | cells { | |
| 584 | + | cell "You keep"; | |
| 585 | + | cell outcome.mnw_keep.clone(); | |
| 586 | + | cell outcome.other_keep.clone(); | |
| 587 | + | } | |
| 588 | + | cells { | |
| 589 | + | cell "Total fees"; | |
| 590 | + | cell outcome.mnw_rate.clone(); | |
| 591 | + | cell outcome.other_rate.clone(); | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | ||
| 595 | + | // Where the reader's volume sits against the crossover, which is the | |
| 596 | + | // whole of what the hand-drawn two-segment bar said. Absent when there | |
| 597 | + | // is no crossover, because there is then no set to be a proportion of, | |
| 598 | + | // and the note below says why in words. An `Option` is an iterator of | |
| 599 | + | // at most one. | |
| 600 | + | for at in outcome.crossover_sales.into_iter() { | |
| 601 | + | section "Where each one wins"; | |
| 602 | + | include crossover_meter(outcome, at); | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | for note in outcome.crossover_note.iter() { | |
| 606 | + | text note; | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | text "Our side of this uses the payment processor's published US card rates. \ | |
| 610 | + | Yours is whatever you type in, so check it against a real payout rather \ | |
| 611 | + | than a pricing page: some platforms quote their cut before processing \ | |
| 612 | + | and some after."; | |
| 613 | + | text "Selling small-ticket items? Payment processors charge a fixed fee per \ | |
| 614 | + | sale (~$0.30) that hits harder on $1-5 items. That is an industry-wide \ | |
| 615 | + | constraint and it applies wherever you sell. Bundling items into \ | |
| 616 | + | collections lets fans buy in groups at a single transaction cost \ | |
| 617 | + | instead of paying per-item processing."; | |
| 587 | 618 | } | |
| 588 | - | ||
| 589 | - | if let Some(note) = &outcome.crossover_note { | |
| 590 | - | slot = slot.with(Node::text(note)); | |
| 591 | - | } | |
| 592 | - | ||
| 593 | - | slot.with(Node::text( | |
| 594 | - | "Our side of this uses the payment processor's published US card rates. \ | |
| 595 | - | Yours is whatever you type in, so check it against a real payout rather \ | |
| 596 | - | than a pricing page: some platforms quote their cut before processing \ | |
| 597 | - | and some after.", | |
| 598 | - | )) | |
| 599 | - | .with(Node::text( | |
| 600 | - | "Selling small-ticket items? Payment processors charge a fixed fee per \ | |
| 601 | - | sale (~$0.30) that hits harder on $1-5 items. That is an industry-wide \ | |
| 602 | - | constraint and it applies wherever you sell. Bundling items into \ | |
| 603 | - | collections lets fans buy in groups at a single transaction cost \ | |
| 604 | - | instead of paying per-item processing.", | |
| 605 | - | )) | |
| 606 | 619 | } | |
| 607 | 620 | ||
| 608 | 621 | /// What a verdict means, as every renderer already draws it. | |
| 609 | 622 | /// | |
| 623 | + | /// Named `verdict_tone` rather than `tone` because `tone` is a setting in a | |
| 624 | + | /// declaration body and a supplier wearing a setting's name reads as one. | |
| 625 | + | /// | |
| 610 | 626 | /// The template branched on `Verdict::css_class`, three class names this | |
| 611 | 627 | /// server's own stylesheet defined. A tone says the same thing in a word every | |
| 612 | 628 | /// host has. | |
| 613 | - | const fn tone(verdict: Verdict) -> layout::Tone { | |
| 629 | + | const fn verdict_tone(verdict: Verdict) -> layout::Tone { |
Lines truncated
| @@ -64,10 +64,8 @@ | |||
| 64 | 64 | //! page cannot drift: one file, two callers. | |
| 65 | 65 | ||
| 66 | 66 | use makeover_layout as layout; | |
| 67 | - | use quasi_router::screen::{Field, Image, Row}; | |
| 68 | - | use quasi_router::{ | |
| 69 | - | Act, Action, Document, Feed, FeedKind, Node, RegionKind, Screen as Described, Slot, Tag, | |
| 70 | - | }; | |
| 67 | + | use quasi_declare::declare; | |
| 68 | + | use quasi_router::{Action, Document, Feed, FeedKind, Node, RegionKind, Tag}; | |
| 71 | 69 | use quasi_webview::Webview; | |
| 72 | 70 | ||
| 73 | 71 | use crate::templates::CarouselFrame; | |
| @@ -98,6 +96,17 @@ | |||
| 98 | 96 | format!("/p/{slug}/rss") | |
| 99 | 97 | } | |
| 100 | 98 | ||
| 99 | + | /// One repository linked to this project. | |
| 100 | + | /// | |
| 101 | + | /// Named members rather than a tuple, for `policy`'s reason: a description | |
| 102 | + | /// names what it draws, and `.1` is not a name. | |
| 103 | + | pub struct LinkedRepo { | |
| 104 | + | /// What the repository is called. | |
| 105 | + | pub name: String, | |
| 106 | + | /// Where it is on this site. | |
| 107 | + | pub url: String, | |
| 108 | + | } | |
| 109 | + | ||
| 101 | 110 | /// Everything the screen is about. | |
| 102 | 111 | /// | |
| 103 | 112 | /// A struct rather than twenty arguments, for [`super::user::Profile`]'s | |
| @@ -122,8 +131,8 @@ | |||
| 122 | 131 | pub gallery: &'a [CarouselFrame], | |
| 123 | 132 | /// The tiers a reader may subscribe to. | |
| 124 | 133 | pub tiers: &'a [SubscriptionTier], | |
| 125 | - | /// Linked repositories, as name and address. | |
| 126 | - | pub git_repos: &'a [(String, String)], | |
| 134 | + | /// Linked repositories. | |
| 135 | + | pub git_repos: &'a [LinkedRepo], | |
| 127 | 136 | /// The paired forum, when one is provisioned. | |
| 128 | 137 | pub community_url: Option<&'a str>, | |
| 129 | 138 | /// How many people follow it. | |
| @@ -157,6 +166,45 @@ | |||
| 157 | 166 | .unwrap_or_else(|| format!("{}/static/images/og-card.png", self.host_url)) | |
| 158 | 167 | } | |
| 159 | 168 | ||
| 169 | + | /// Whether there is cover art to draw. | |
| 170 | + | /// | |
| 171 | + | /// A predicate rather than an `Option` the description reaches into, which | |
| 172 | + | /// is the call `super::embeds::ItemView::has_cover` records. | |
| 173 | + | fn has_cover(&self) -> bool { | |
| 174 | + | !self.cover().is_empty() | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// The cover's address, or nothing. R9: the picture is built either way. | |
| 178 | + | fn cover(&self) -> &str { | |
| 179 | + | self.project | |
| 180 | + | .cover_image_url | |
| 181 | + | .as_deref() | |
| 182 | + | .map(str::trim) | |
| 183 | + | .unwrap_or_default() | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// What a tip on this page is about. | |
| 187 | + | /// | |
| 188 | + | /// A method rather than the literal the caller used to write inline: a | |
| 189 | + | /// `Type { .. }` aggregate is the form's hard limit. | |
| 190 | + | fn tip_offer(&self) -> super::tip::Offer<'_> { | |
| 191 | + | super::tip::Offer { | |
| 192 | + | creator_id: self.creator_id, | |
| 193 | + | project_id: Some(self.project_id), | |
| 194 | + | signed_in: self.signed_in, | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// Whether this reader is offered the subscribe form. | |
| 199 | + | fn offers_subscribe(&self) -> bool { | |
| 200 | + | !self.has_subscription && self.signed_in | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /// Whether they are offered the way to get a session instead. | |
| 204 | + | fn must_sign_in(&self) -> bool { | |
| 205 | + | !self.has_subscription && !self.signed_in | |
| 206 | + | } | |
| 207 | + | ||
| 160 | 208 | /// The structured data the template opened a `CollectionPage` block with. | |
| 161 | 209 | /// | |
| 162 | 210 | /// Escaped for JSON rather than for HTML, which is what it is: a JSON | |
| @@ -181,277 +229,245 @@ | |||
| 181 | 229 | } | |
| 182 | 230 | } | |
| 183 | 231 | ||
| 184 | - | /// One item, as a row. | |
| 232 | + | /// Whether an item has cover art to draw. | |
| 185 | 233 | /// | |
| 186 | - | /// Everything the card carried, in the roles the vocabulary has for them: the | |
| 187 | - | /// cover and the title are what the row is called, the type and the date are | |
| 188 | - | /// its meta, the description is its secondary line, the tags are its tokens, | |
| 189 | - | /// and the way to get it is what it offers. | |
| 190 | - | fn item_row(item: &Item) -> Row { | |
| 191 | - | let destination = if item.can_access { | |
| 234 | + | /// A free function rather than a method, because `Item` is `crate::types`' and | |
| 235 | + | /// this is a fact this screen wants rather than a fact about the item. | |
| 236 | + | fn item_has_cover(item: &Item) -> bool { | |
| 237 | + | item.cover().is_some() | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// Its address, or nothing. R9: the picture is built either way. | |
| 241 | + | fn item_cover(item: &Item) -> &str { | |
| 242 | + | item.cover().unwrap_or_default() | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// The trailing line: what kind of thing it is, how many it holds, and when it | |
| 246 | + | /// came out. | |
| 247 | + | fn item_meta(item: &Item) -> String { | |
| 248 | + | if item.bundle_item_count > 0 { | |
| 249 | + | format!( | |
| 250 | + | "{} ({} items) - {}", | |
| 251 | + | item.item_type, item.bundle_item_count, item.release_date | |
| 252 | + | ) | |
| 253 | + | } else { | |
| 254 | + | format!("{} - {}", item.item_type, item.release_date) | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// Where opening the row goes: the item, or the way to buy it. | |
| 259 | + | fn item_destination(item: &Item) -> String { | |
| 260 | + | if item.can_access { | |
| 192 | 261 | format!("/i/{}", item.id) | |
| 193 | 262 | } else { | |
| 194 | 263 | format!("/purchase/{}", item.id) | |
| 195 | - | }; | |
| 196 | - | ||
| 197 | - | let mut row = Row::default(); | |
| 198 | - | if let Some(cover) = item.cover() { | |
| 199 | - | row = row.part( | |
| 200 | - | layout::RowPart::Primary, | |
| 201 | - | Node::Image(Image::new(cover, item.title.clone()).lazy()), | |
| 202 | - | ); | |
| 203 | - | } | |
| 204 | - | row = row | |
| 205 | - | .part(layout::RowPart::Primary, Node::text(item.title.clone())) | |
| 206 | - | .meta(if item.bundle_item_count > 0 { | |
| 207 | - | format!( | |
| 208 | - | "{} ({} items) - {}", | |
| 209 | - | item.item_type, item.bundle_item_count, item.release_date | |
| 210 | - | ) | |
| 211 | - | } else { | |
| 212 | - | format!("{} - {}", item.item_type, item.release_date) | |
| 213 | - | }) | |
| 214 | - | .secondary(item.description.clone()) | |
| 215 | - | .relaxed(); | |
| 216 | - | ||
| 217 | - | for tag in &item.tags { | |
| 218 | - | row = row.token(Tag::chip( | |
| 219 | - | tag.name.clone(), | |
| 220 | - | Action::get("/discover") | |
| 221 | - | .carrying("tag", tag.slug.clone()) | |
| 222 | - | .navigating(), | |
| 223 | - | )); | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | row = row | |
| 227 | - | .part(layout::RowPart::Meta, Node::text(item.price.clone())) | |
| 228 | - | .part( | |
| 229 | - | layout::RowPart::Meta, | |
| 230 | - | Node::text(format!("{} sales", item.sales_count)), | |
| 231 | - | ) | |
| 232 | - | .activate(Action::get(&destination).navigating()); | |
| 233 | - | ||
| 234 | - | // The one way in, per state. The template drew four and so does this. | |
| 235 | - | if item.can_access { | |
| 236 | - | row.act(Act::new( | |
| 237 | - | "View in library", | |
| 238 | - | Action::get(format!("/l/{}", item.id)).navigating(), | |
| 239 | - | )) | |
| 240 | - | } else if item.is_free { | |
| 241 | - | row.act(Act::new( | |
| 242 | - | "Add to Library", | |
| 243 | - | Action::post(format!("/api/library/add/{}", item.id)).invalidating(), | |
| 244 | - | )) | |
| 245 | - | } else if item.pwyw_enabled { | |
| 246 | - | row.act(Act::new( | |
| 247 | - | "Pay What You Want", | |
| 248 | - | Action::get(&destination).navigating(), | |
| 249 | - | )) | |
| 250 | - | } else { | |
| 251 | - | row.act(Act::new("Buy Once", Action::get(&destination).navigating())) | |
| 252 | 264 | } | |
| 253 | 265 | } | |
| 254 | 266 | ||
| 255 | - | /// One membership tier, as a group. | |
| 256 | - | fn tier_group(store: &Store<'_>, tier: &SubscriptionTier) -> Node { | |
| 257 | - | let mut group = Slot::new(format!("tier-{}", tier.id), RegionKind::Group) | |
| 258 | - | .with(Node::section(tier.name.clone())) | |
| 259 | - | .with(Node::text(tier.price.clone())); | |
| 260 | - | ||
| 261 | - | if !tier.description.is_empty() { | |
| 262 | - | group = group.with(Node::text(tier.description.clone())); | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | group = if store.has_subscription { | |
| 266 | - | group.with(Node::Token(Tag::badge("Subscribed"))) | |
| 267 | - | } else if store.signed_in { | |
| 268 | - | let mut promo = Field::new( | |
| 269 | - | layout::FieldKind::Text, | |
| 270 | - | "promo_code", | |
| 271 | - | "Promo code (optional)", | |
| 272 | - | ); | |
| 273 | - | promo.placeholder = Some("e.g. TRIAL14".to_owned()); | |
| 274 | - | group.with(Node::Form { | |
| 275 | - | // The hidden `_csrf` goes the way the tip form's did: this arrives | |
| 276 | - | // as an htmx post and `frontend/src/core/htmx-glue.ts` attaches the | |
| 277 | - | // token from the document's meta. `create_subscription_checkout` | |
| 278 | - | // ends at Stripe, so it answers `HX-Redirect` to an htmx caller. | |
| 279 | - | action: Action::post(format!("/stripe/subscribe/{}", tier.id)), | |
| 280 | - | submit: "Subscribe".to_owned(), | |
| 281 | - | fields: vec![promo], | |
| 282 | - | }) | |
| 283 | - | } else { | |
| 284 | - | group.with(Node::act( | |
| 285 | - | "Log in to Subscribe", | |
| 286 | - | Action::get("/login").navigating(), | |
| 287 | - | )) | |
| 288 | - | }; | |
| 289 | - | ||
| 290 | - | Node::Region(group) | |
| 267 | + | /// The reader already has it. | |
| 268 | + | fn offers_library(item: &Item) -> bool { | |
| 269 | + | item.can_access | |
| 291 | 270 | } | |
| 292 | 271 | ||
| 293 | - | /// The whole document. | |
| 294 | - | #[must_use] | |
| 295 | - | pub fn screen(store: &Store<'_>, theme_css: &str) -> Described { | |
| 296 | - | let project = store.project; | |
| 297 | - | let feed = feed_path(&project.slug); | |
| 272 | + | /// It costs nothing and they can take it. | |
| 273 | + | fn offers_free(item: &Item) -> bool { | |
| 274 | + | !item.can_access && item.is_free | |
| 275 | + | } | |
| 298 | 276 | ||
| 299 | - | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane); | |
| 277 | + | /// They name their own price. | |
| 278 | + | fn offers_pwyw(item: &Item) -> bool { | |
| 279 | + | !item.can_access && !item.is_free && item.pwyw_enabled | |
| 280 | + | } | |
| 300 | 281 | ||
| 301 | - | if let Some(cover) = project | |
| 302 | - | .cover_image_url | |
| 303 | - | .as_deref() | |
| 304 | - | .map(str::trim) | |
| 305 | - | .filter(|url| !url.is_empty()) | |
| 306 | - | { | |
| 307 | - | page = page.with(Node::Image(Image::new(cover, project.title.clone()))); | |
| 308 | - | } | |
| 282 | + | /// They pay the listed price. The fourth of four, and the four are exhaustive | |
| 283 | + | /// and disjoint by construction. | |
| 284 | + | fn offers_purchase(item: &Item) -> bool { | |
| 285 | + | !item.can_access && !item.is_free && !item.pwyw_enabled | |
| 286 | + | } | |
| 309 | 287 | ||
| 310 | - | page = page | |
| 311 | - | .with(Node::page(project.title.clone())) | |
| 312 | - | .with(Node::Link { | |
| 313 | - | text: store.creator_username.to_owned(), | |
| 314 | - | action: Action::get(format!("/u/{}", store.creator_username)).navigating(), | |
| 315 | - | }) | |
| 316 | - | .with(Node::text(format!("{} items", project.item_count))) | |
| 317 | - | .with(Node::text(project.description.clone())); | |
| 288 | + | declare! { | |
| 289 | + | /// One item, as a row. | |
| 290 | + | /// | |
| 291 | + | /// Everything the card carried, in the roles the vocabulary has for them: | |
| 292 | + | /// the cover and the title are what the row is called, the type and the | |
| 293 | + | /// date are its meta, the description is its secondary line, the tags are | |
| 294 | + | /// its tokens, and the way to get it is what it offers. | |
| 295 | + | /// | |
| 296 | + | /// The one way in, per state. The template drew four and so does this, as | |
| 297 | + | /// four guarded controls rather than a dispatch: a dispatch arm is one | |
| 298 | + | /// emission and these are one each, but the guards say which four states | |
| 299 | + | /// they answer to and a reader can check they are exhaustive. | |
| 300 | + | shape item_row(item: &Item) -> Row; | |
| 318 | 301 | ||
| 319 | - | if store.is_owner { | |
| 320 | - | page = page.with(Node::act( | |
| 321 | - | "Edit Project", | |
| 322 | - | Action::get(format!("/dashboard/project/{}", project.slug)).navigating(), | |
| 323 | - | )); | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | if store.signed_in { | |
| 327 | - | page = page.with(super::follow::control( | |
| 328 | - | "project", | |
| 329 | - | store.project_id, | |
| 330 | - | store.is_following, | |
| 331 | - | store.follower_count, | |
| 332 | - | )); | |
| 333 | - | } else if let Some(count) = super::follow::count_only(store.follower_count) { | |
| 334 | - | page = page.with(count); | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | page = page.with(Node::act( | |
| 338 | - | "RSS Feed", | |
| 339 | - | Action::get(feed.clone()).navigating(), | |
| 340 | - | )); | |
| 341 | - | ||
| 342 | - | if store.has_blog_posts { | |
| 343 | - | page = page.with(Node::act( | |
| 344 | - | "Blog", | |
| 345 | - | Action::get(format!("/p/{}/blog", project.slug)).navigating(), | |
| 346 | - | )); | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | for (name, url) in store.git_repos { | |
| 350 | - | page = page.with(Node::act( | |
| 351 | - | format!("Git ({name})"), | |
| 352 | - | Action::get(url.clone()).navigating(), | |
| 353 | - | )); | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | if let Some(url) = store.community_url { | |
| 357 | - | // The forum is a separate deployment on its own host, and a reader is | |
| 358 | - | // expected to come back, which is what `External` says. | |
| 359 | - | page = page.with(Node::act("Community", Action::external(url.to_owned()))); | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | if store.tips_enabled { | |
| 363 | - | page = page.with(super::tip::control(&super::tip::Offer { | |
| 364 | - | creator_id: store.creator_id, | |
| 365 | - | project_id: Some(store.project_id), | |
| 366 | - | signed_in: store.signed_in, | |
| 367 | - | })); | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | if !store.gallery.is_empty() { | |
| 371 | - | // The shipped widget rather than a fourth hand-rolled carousel: | |
| 372 | - | // `super::widgets::carousel` is the one place a template frame becomes | |
| 373 | - | // a described one, and the Askama macro the item page still calls goes | |
| 374 | - | // through it too. | |
| 375 | - | page = page.with(super::widgets::carousel::region( | |
| 376 | - | GALLERY_REGION, | |
| 377 | - | store.gallery, | |
| 378 | - | )); | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | if !store.sections.is_empty() { | |
| 382 | - | let mut tabs = Slot::new(SECTIONS_REGION, RegionKind::TabGroup).showing_one(0); | |
| 383 | - | for section in store.sections { | |
| 384 | - | tabs = tabs.with(Node::Region( | |
| 385 | - | Slot::handover(panel_region(§ion.slug), "a project section") | |
| 386 | - | .label(section.title.clone()), | |
| 387 | - | )); | |
| 302 | + | row "" { | |
| 303 | + | beside Primary picture item_cover(item) item.title.clone() | |
| 304 | + | when item_has_cover(item) { | |
| 305 | + | lazy; | |
| 388 | 306 | } | |
| 389 | - | page = page.with(Node::Region(tabs)); | |
| 307 | + | beside Primary text item.title.clone(); | |
| 308 | + | meta item_meta(item); | |
| 309 | + | secondary item.description.clone(); | |
| 310 | + | relaxed; | |
| 311 | + | ||
| 312 | + | for tag in item.tags.iter() { | |
| 313 | + | token Tag::chip( | |
| 314 | + | tag.name.clone(), | |
| 315 | + | Action::get("/discover").carrying("tag", tag.slug.clone()).navigating() | |
| 316 | + | ); | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | beside Meta text item.price.clone(); | |
| 320 | + | beside Meta text "{item.sales_count} sales"; | |
| 321 | + | activate to get item_destination(item) navigating; | |
| 322 | + | ||
| 323 | + | act "View in library" to get "/l/{item.id}" navigating when offers_library(item); | |
| 324 | + | act "Add to Library" to post "/api/library/add/{item.id}" invalidating | |
| 325 | + | when offers_free(item); | |
| 326 | + | act "Pay What You Want" to get item_destination(item) navigating | |
| 327 | + | when offers_pwyw(item); | |
| 328 | + | act "Buy Once" to get item_destination(item) navigating when offers_purchase(item); | |
| 390 | 329 | } | |
| 330 | + | } | |
| 391 | 331 | ||
| 392 | - | page = page | |
| 393 | - | .with(Node::section("Available Items")) | |
| 394 | - | .with(if store.items.is_empty() { | |
| 395 | - | Node::empty("Nothing published here yet.") | |
| 396 | - | } else { | |
| 397 | - | Node::list(store.items.iter().map(item_row)) | |
| 398 | - | }); | |
| 332 | + | /// The badge a subscriber wears on a tier they already hold. | |
| 333 | + | /// | |
| 334 | + | /// A supplier and not a member: `token` is a **setting** on a row and on a | |
| 335 | + | /// cell, so the name cannot also be a node member. The third instance of that | |
| 336 | + | /// forcing after both analytics panels' `chip`, and quasicoherent `e2030032` | |
| 337 | + | /// is where it is being decided. | |
| 338 | + | fn subscribed_badge() -> Node { | |
| 339 | + | Node::token(Tag::badge("Subscribed")) | |
| 340 | + | } | |
| 399 | 341 | ||
| 400 | - | if !store.tiers.is_empty() { | |
| 401 | - | page = page.with(Node::section("Membership")); | |
| 402 | - | for tier in store.tiers { | |
| 403 | - | page = page.with(tier_group(store, tier)); | |
| 342 | + | declare! { | |
| 343 | + | /// One membership tier, as a group. | |
| 344 | + | shape tier_group(store: &Store<'_>, tier: &SubscriptionTier) -> Node; | |
| 345 | + | ||
| 346 | + | region "tier-{tier.id}" as Group { | |
| 347 | + | section tier.name.clone(); | |
| 348 | + | text tier.price.clone(); | |
| 349 | + | text tier.description.clone() unless tier.description.is_empty(); | |
| 350 | + | ||
| 351 | + | include subscribed_badge() when store.has_subscription; | |
| 352 | + | ||
| 353 | + | // The hidden `_csrf` goes the way the tip form's did: this arrives as | |
| 354 | + | // an htmx post and `frontend/src/core/htmx-glue.ts` attaches the token | |
| 355 | + | // from the document's meta. `create_subscription_checkout` ends at | |
| 356 | + | // Stripe, so it answers `HX-Redirect` to an htmx caller. | |
| 357 | + | form post "/stripe/subscribe/{tier.id}" when store.offers_subscribe() { | |
| 358 | + | submit "Subscribe"; | |
| 359 | + | field Text "promo_code" "Promo code (optional)" { | |
| 360 | + | placeholder "e.g. TRIAL14"; | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | act "Log in to Subscribe" to get "/login" navigating when store.must_sign_in(); | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | declare! { | |
| 369 | + | /// The whole document. | |
| 370 | + | #[must_use] | |
| 371 | + | pub shape screen(store: &Store<'_>, theme_css: &str) -> Screen; | |
| 372 | + | ||
| 373 | + | let feed = feed_path(&store.project.slug); | |
| 374 | + | ||
| 375 | + | screen single "{store.project.title} - {store.creator_username}" { | |
| 376 | + | measured MEASURE; | |
| 377 | + | documented Document::default() | |
| 378 | + | .classed(crate::shell::body_class(MEASURE, &["project-page"])) | |
| 379 | + | // Tier 0, as `super::user` carries the creator's. | |
| 380 | + | .styled(theme_css.to_owned()); | |
| 381 | + | summarised store.project.description.clone(); | |
| 382 | + | illustrated store.image(); | |
| 383 | + | canonical_at store.canonical(); | |
| 384 | + | about quasi_router::SocialKind::Product; | |
| 385 | + | syndicating Feed::new(FeedKind::Rss, "{store.project.title} - RSS Feed", feed); | |
| 386 | + | ||
| 387 | + | region PAGE_REGION as Pane { | |
| 388 | + | picture store.cover() store.project.title.clone() when store.has_cover(); | |
| 389 | + | ||
| 390 | + | page store.project.title.clone(); | |
| 391 | + | link store.creator_username to get "/u/{store.creator_username}" navigating; | |
| 392 | + | text "{store.project.item_count} items"; | |
| 393 | + | text store.project.description.clone(); | |
| 394 | + | ||
| 395 | + | act "Edit Project" to get "/dashboard/project/{store.project.slug}" navigating | |
| 396 | + | when store.is_owner; | |
| 397 | + | ||
| 398 | + | include super::follow::control( | |
| 399 | + | "project", | |
| 400 | + | store.project_id, | |
| 401 | + | store.is_following, | |
| 402 | + | store.follower_count | |
| 403 | + | ) when store.signed_in; | |
| 404 | + | // An `-> Option<_>` shape is placed with a loop, because an | |
| 405 | + | // `Option` is an iterator of at most one. | |
| 406 | + | for counted in super::follow::count_only(store.follower_count).into_iter() { | |
| 407 | + | include counted unless store.signed_in; | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | act "RSS Feed" to get feed.clone() navigating; | |
| 411 | + | act "Blog" to get "/p/{store.project.slug}/blog" navigating | |
| 412 | + | when store.has_blog_posts; | |
| 413 | + | ||
| 414 | + | for repo in store.git_repos.iter() { | |
| 415 | + | act "Git ({repo.name})" to get repo.url.clone() navigating; | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | // The forum is a separate deployment on its own host, and a reader | |
| 419 | + | // is expected to come back, which is what `external` says. | |
| 420 | + | for url in store.community_url.into_iter() { | |
| 421 | + | act "Community" to external url; | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | include super::tip::control(&store.tip_offer()) when store.tips_enabled; | |
| 425 | + | ||
| 426 | + | // The shipped widget rather than a fourth hand-rolled carousel: | |
| 427 | + | // `super::widgets::carousel` is the one place a template frame | |
| 428 | + | // becomes a described one, and the Askama macro the item page still | |
| 429 | + | // calls goes through it too. | |
| 430 | + | include super::widgets::carousel::region(GALLERY_REGION, store.gallery) | |
| 431 | + | unless store.gallery.is_empty(); | |
| 432 | + | ||
| 433 | + | region SECTIONS_REGION as TabGroup unless store.sections.is_empty() { | |
| 434 | + | showing_one 0; | |
| 435 | + | for section in store.sections.iter() { | |
| 436 | + | region panel_region(§ion.slug) as RegionKind::handover("a project section") { | |
| 437 | + | label section.title.clone(); | |
| 438 | + | } | |
| 439 | + | } | |
| 440 | + | } | |
| 441 | + |
Lines truncated
| @@ -77,7 +77,8 @@ | |||
| 77 | 77 | //! it survives a swap, which the class did not. | |
| 78 | 78 | ||
| 79 | 79 | use makeover_layout as layout; | |
| 80 | - | use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Consult, Field, Table, Tag}; | |
| 80 | + | use quasi_declare::declare; | |
| 81 | + | use quasi_router::screen::{Choice, Consult, Field, Tag}; | |
| 81 | 82 | use quasi_router::{Action, Node, RegionKind, Slot}; | |
| 82 | 83 | use quasi_webview::Webview; | |
| 83 | 84 | ||
| @@ -337,6 +338,67 @@ | |||
| 337 | 338 | } | |
| 338 | 339 | } | |
| 339 | 340 | ||
| 341 | + | /// The same view with every narrowing dropped and the order kept. | |
| 342 | + | /// | |
| 343 | + | /// What "Clear filters" is offered under. A method rather than the literal | |
| 344 | + | /// the caller used to write inline: a `Type { .. }` aggregate is the form's | |
| 345 | + | /// hard limit. | |
| 346 | + | fn cleared(&self) -> Self { | |
| 347 | + | Self { | |
| 348 | + | sort: self.sort, | |
| 349 | + | descending: self.descending, | |
| 350 | + | ..Self::default() | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | /// The same view with every row arriving ticked. See [`Self::cleared`]. | |
| 355 | + | fn all_ticked(&self) -> Self { | |
| 356 | + | Self { | |
| 357 | + | ticked: true, | |
| 358 | + | ..self.clone() | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /// What the search box holds, or nothing. | |
| 363 | + | fn query_text(&self) -> String { | |
| 364 | + | self.query.clone().unwrap_or_default() | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | /// Which status is in force, or nothing. | |
| 368 | + | fn status_text(&self) -> String { | |
| 369 | + | self.status.clone().unwrap_or_default() | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | /// Which type is in force, or nothing. | |
| 373 | + | fn kind_text(&self) -> String { | |
| 374 | + | self.kind.clone().unwrap_or_default() | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | /// Whether this bundle's children are showing. | |
| 378 | + | fn is_open(&self, bundle: &str) -> bool { | |
| 379 | + | self.open.iter().any(|id| id == bundle) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | /// Which way the table is ordered. R9: read whether or not it is placed. | |
| 383 | + | fn direction(&self) -> layout::Sort { | |
| 384 | + | if self.descending { | |
| 385 | + | layout::Sort::Descending | |
| 386 | + | } else { | |
| 387 | + | layout::Sort::Ascending | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | /// Whether this column is the one being ordered by. | |
| 392 | + | fn orders_by(&self, sort: SortBy) -> bool { | |
| 393 | + | self.sort == Some(sort) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | /// Whether the reorder arrows mean anything, which is only under the | |
| 397 | + | /// project's own order. | |
| 398 | + | fn keeps_order(&self) -> bool { | |
| 399 | + | self.sort.is_none() | |
| 400 | + | } | |
| 401 | + | ||
| 340 | 402 | /// Whether anything is hidden. What "Clear filters" is offered for, and it | |
| 341 | 403 | /// ignores the sort and the open bundles: neither hides a row. | |
| 342 | 404 | #[must_use] | |
| @@ -421,15 +483,61 @@ | |||
| 421 | 483 | } | |
| 422 | 484 | } | |
| 423 | 485 | ||
| 424 | - | /// The ghost text a field shows while it is empty. | |
| 486 | + | /// One sortable heading: what it says, what it orders, and how it sits. | |
| 425 | 487 | /// | |
| 426 | - | /// `Field::placeholder` is a public member with no builder beside it, so this is | |
| 427 | - | /// the one line that would otherwise be a `let mut` in three places. | |
| 428 | - | fn ghost(mut field: Field, text: &str) -> Field { | |
| 429 | - | field.placeholder = Some(text.to_owned()); | |
| 430 | - | field | |
| 488 | + | /// Named members rather than a tuple, for `policy`'s reason: a description | |
| 489 | + | /// names what it draws, and `.1` is not a name. | |
| 490 | + | struct Sortable { | |
| 491 | + | /// The heading. | |
| 492 | + | name: &'static str, | |
| 493 | + | /// What pressing it orders by. | |
| 494 | + | sort: SortBy, | |
| 495 | + | /// How much room it asks for. | |
| 496 | + | width: layout::Width, | |
| 497 | + | /// What it is worth on a narrow viewport. | |
| 498 | + | priority: layout::Priority, | |
| 431 | 499 | } | |
| 432 | 500 | ||
| 501 | + | /// The six sortable headings, in column order. | |
| 502 | + | const SORTABLE: &[Sortable] = &[ | |
| 503 | + | Sortable { | |
| 504 | + | name: "Item", | |
| 505 | + | sort: SortBy::Item, | |
| 506 | + | width: layout::Width::Fill, | |
| 507 | + | priority: layout::Priority::Essential, | |
| 508 | + | }, | |
| 509 | + | Sortable { | |
| 510 | + | name: "Type", | |
| 511 | + | sort: SortBy::Kind, | |
| 512 | + | width: layout::Width::Content, | |
| 513 | + | priority: layout::Priority::Secondary, | |
| 514 | + | }, | |
| 515 | + | Sortable { | |
| 516 | + | name: "Price", | |
| 517 | + | sort: SortBy::Price, | |
| 518 | + | width: layout::Width::Content, | |
| 519 | + | priority: layout::Priority::Secondary, | |
| 520 | + | }, | |
| 521 | + | Sortable { | |
| 522 | + | name: "Sales", | |
| 523 | + | sort: SortBy::Sales, | |
| 524 | + | width: layout::Width::Content, | |
| 525 | + | priority: layout::Priority::Optional, | |
| 526 | + | }, | |
| 527 | + | Sortable { | |
| 528 | + | name: "Revenue", | |
| 529 | + | sort: SortBy::Revenue, | |
| 530 | + | width: layout::Width::Content, | |
| 531 | + | priority: layout::Priority::Optional, | |
| 532 | + | }, | |
| 533 | + | Sortable { | |
| 534 | + | name: "Status", | |
| 535 | + | sort: SortBy::Status, | |
| 536 | + | width: layout::Width::Content, | |
| 537 | + | priority: layout::Priority::Essential, | |
| 538 | + | }, | |
| 539 | + | ]; | |
| 540 | + | ||
| 433 | 541 | /// Everything inside the Content panel, as the tab strip's fill. | |
| 434 | 542 | /// | |
| 435 | 543 | /// Without the region wrapper, because the strip already draws one carrying | |
| @@ -475,485 +583,440 @@ | |||
| 475 | 583 | Webview::new().fragment(&Node::Region(slot)) | |
| 476 | 584 | } | |
| 477 | 585 | ||
| 478 | - | /// The panel's contents, in order. | |
| 479 | - | fn body( | |
| 480 | - | slug: &str, | |
| 481 | - | items: &[ContentItem], | |
| 482 | - | deleted: &[DeletedItemRow], | |
| 483 | - | posts: &[BlogPostDashboardRow], | |
| 484 | - | view: &View, | |
| 485 | - | ) -> Vec<Node> { | |
| 486 | - | let mut out = vec![ | |
| 487 | - | Node::section("Items"), | |
| 488 | - | Node::act( | |
| 489 | - | "New Item", | |
| 490 | - | // A whole page rather than a fragment, so it leaves: see | |
| 491 | - | // `quasi::forum_memberships` for the same spelling. An internal | |
| 492 | - | // `Action::get` would fetch the wizard into this panel. | |
| 493 | - | Action::external(format!("/dashboard/project/{slug}/new-item")), | |
| 494 | - | ), | |
| 495 | - | ]; | |
| 586 | + | declare! { | |
| 587 | + | /// The panel's contents, in order. | |
| 588 | + | shape body( | |
| 589 | + | slug: &str, | |
| 590 | + | items: &[ContentItem], | |
| 591 | + | deleted: &[DeletedItemRow], | |
| 592 | + | posts: &[BlogPostDashboardRow], | |
| 593 | + | view: &View, | |
| 594 | + | ) -> Vec<Node>; | |
| 496 | 595 | ||
| 497 | - | if items.is_empty() { | |
| 498 | - | // The project's own empty state, which is a different sentence from a | |
| 499 | - | // filter matching nothing and is the only one that offers a way to make | |
| 500 | - | // the first item. | |
| 501 | - | out.push( | |
| 502 | - | Node::empty("No items in this project yet. Add your first item to start publishing.") | |
| 503 | - | .offering(Act::new( | |
| 504 | - | "Add First Item", | |
| 505 | - | Action::external(format!("/dashboard/project/{slug}/new-item")), | |
| 506 | - | )), | |
| 507 | - | ); | |
| 508 | - | out.push(Node::text( | |
| 509 | - | "After creating an item, set its pricing and publish it to make it available to fans.", | |
| 510 | - | )); | |
| 511 | - | } else { | |
| 512 | - | out.push(Node::Region(filters(slug, items, view))); | |
| 513 | - | out.push(Node::Region(bulk(slug, view))); | |
| 596 | + | let shown = view.shown(items); | |
| 514 | 597 | ||
| 515 | - | let shown = view.shown(items); | |
| 516 | - | if shown.is_empty() { | |
| 517 | - | out.push( | |
| 518 | - | Node::empty("No items match these filters.").offering(Act::new( | |
| 519 | - | "Clear filters", | |
| 520 | - | View { | |
| 521 | - | sort: view.sort, | |
| 522 | - | descending: view.descending, | |
| 523 | - | ..View::default() | |
| 524 | - | } | |
| 525 | - | .panel(slug), | |
| 526 | - | )), | |
| 527 | - | ); | |
| 528 | - | } else { | |
| 529 | - | out.push(table(slug, view, &shown)); | |
| 530 | - | } | |
| 598 | + | section "Items"; | |
| 599 | + | // A whole page rather than a fragment, so it leaves: see | |
| 600 | + | // `quasi::forum_memberships` for the same spelling. An internal `get` would | |
| 601 | + | // fetch the wizard into this panel. | |
| 602 | + | act "New Item" to external "/dashboard/project/{slug}/new-item"; | |
| 603 | + | ||
| 604 | + | // The project's own empty state, which is a different sentence from a | |
| 605 | + | // filter matching nothing and is the only one that offers a way to make the | |
| 606 | + | // first item. | |
| 607 | + | empty "No items in this project yet. Add your first item to start publishing." | |
| 608 | + | when items.is_empty() { | |
| 609 | + | offering "Add First Item" to external "/dashboard/project/{slug}/new-item"; | |
| 531 | 610 | } | |
| 611 | + | text "After creating an item, set its pricing and publish it to make it available to fans." | |
| 612 | + | when items.is_empty(); | |
| 532 | 613 | ||
| 533 | - | if !deleted.is_empty() { | |
| 534 | - | out.push(Node::section(format!( | |
| 535 | - | "Recently Deleted ({})", | |
| 536 | - | deleted.len() | |
| 537 | - | ))); | |
| 538 | - | out.push(Node::text( | |
| 539 | - | "Deleted items are permanently removed after 7 days.", | |
| 540 | - | )); | |
| 541 | - | out.push(deleted_table(slug, view, deleted)); | |
| 614 | + | include filters(slug, items, view) unless items.is_empty(); | |
| 615 | + | include bulk(slug, view) unless items.is_empty(); | |
| 616 | + | ||
| 617 | + | empty "No items match these filters." when shown.is_empty() and not items.is_empty() { | |
| 618 | + | offering "Clear filters" to doing view.cleared().panel(slug); | |
| 542 | 619 | } | |
| 620 | + | include table(slug, view, &shown) unless shown.is_empty(); | |
| 543 | 621 | ||
| 544 | - | out.push(Node::section("Blog Posts")); | |
| 545 | - | out.push(Node::act( | |
| 546 | - | "New Post", | |
| 547 | - | Action::external(format!("/dashboard/project/{slug}/blog/new")), | |
| 548 | - | )); | |
| 549 | - | if posts.is_empty() { | |
| 550 | - | out.push(Node::empty( | |
| 551 | - | "No blog posts yet. Share updates, release notes, or stories with your audience.", | |
| 552 | - | )); | |
| 553 | - | } else { | |
| 554 | - | out.push(posts_table(slug, view, posts)); | |
| 555 | - | } | |
| 622 | + | section "Recently Deleted ({deleted.len()})" unless deleted.is_empty(); | |
| 623 | + | text "Deleted items are permanently removed after 7 days." unless deleted.is_empty(); | |
| 624 | + | include deleted_table(slug, view, deleted) unless deleted.is_empty(); | |
| 556 | 625 | ||
| 557 | - | out | |
| 626 | + | section "Blog Posts"; | |
| 627 | + | act "New Post" to external "/dashboard/project/{slug}/blog/new"; | |
| 628 | + | empty "No blog posts yet. Share updates, release notes, or stories with your audience." | |
| 629 | + | when posts.is_empty(); | |
| 630 | + | include posts_table(slug, view, posts) unless posts.is_empty(); | |
| 558 | 631 | } | |
| 559 | 632 | ||
| 560 | - | /// The three narrowing controls. | |
| 633 | + | /// The types this project actually holds, offered once each. | |
| 561 | 634 | /// | |
| 562 | - | /// `N12`'s decision in one function: each is a control with a route, and the | |
| 563 | - | /// answer is the panel under the new view. All three ask rather than write -- | |
| 564 | - | /// narrowing a listing puts nothing anywhere -- and the difference between a | |
| 565 | - | /// value still being written and one chosen in a single gesture is the wait: | |
| 566 | - | /// the search box takes the typing wait and the two pickers | |
| 567 | - | /// [`Consult::at_once`]. `aeb44860`, which is where the pickers stopped | |
| 568 | - | /// claiming to be writes. | |
| 569 | - | fn filters(slug: &str, items: &[ContentItem], view: &View) -> Slot { | |
| 570 | - | let narrowed = view.narrowed(); | |
| 571 | - | ||
| 572 | - | let search = ghost( | |
| 573 | - | Field::new(layout::FieldKind::Text, "q", "Search items"), | |
| 574 | - | "Search items...", | |
| 575 | - | ) | |
| 576 | - | .value(view.query.clone().unwrap_or_default()) | |
| 577 | - | .consulting( | |
| 578 | - | Consult::new(narrowed.without_query().panel(slug)) | |
| 579 | - | .after(SEARCH_WAIT) | |
| 580 | - | .at_least(SEARCH_FLOOR), | |
| 581 | - | ); | |
| 582 | - | ||
| 583 | - | let mut statuses = vec![Choice::new("", "All statuses")]; | |
| 584 | - | statuses.extend(STATUSES.iter().map(|status| Choice::new(*status, *status))); | |
| 585 | - | ||
| 586 | - | // The type options are the types this project actually holds. The template | |
| 587 | - | // emitted one `<option>` per item and deleted the duplicates in JS on load; | |
| 588 | - | // counted once here instead. | |
| 589 | - | let mut kinds: Vec<&str> = items.iter().map(|item| item.item_type.as_str()).collect(); | |
| 590 | - | // The type in force stays on the list even when nothing carries it any | |
| 591 | - | // more, which happens when the last item of a kind is deleted or renamed | |
| 592 | - | // under the filter. Without it the picker reads "All types" while the table | |
| 593 | - | // is narrowed, and the control disagrees with the rows beside it. | |
| 635 | + | /// The template emitted one `<option>` per item and deleted the duplicates in | |
| 636 | + | /// JS on load; counted once here instead. The type in force stays on the list | |
| 637 | + | /// even when nothing carries it any more, which happens when the last item of a | |
| 638 | + | /// kind is deleted or renamed under the filter: without it the picker reads | |
| 639 | + | /// "All types" while the table is narrowed, and the control disagrees with the | |
| 640 | + | /// rows beside it. | |
| 641 | + | fn kinds(items: &[ContentItem], view: &View) -> Vec<String> { | |
| 642 | + | let mut kinds: Vec<String> = items.iter().map(|item| item.item_type.clone()).collect(); | |
| 594 | 643 | if let Some(kind) = &view.kind { | |
| 595 | - | kinds.push(kind.as_str()); | |
| 644 | + | kinds.push(kind.clone()); | |
| 596 | 645 | } | |
| 597 | 646 | kinds.sort_unstable(); | |
| 598 | 647 | kinds.dedup(); | |
| 599 | - | let mut types = vec![Choice::new("", "All types")]; | |
| 600 | - | types.extend(kinds.iter().map(|kind| Choice::new(*kind, *kind))); | |
| 601 | - | ||
| 602 | - | Slot::new("content-filters", RegionKind::Group) | |
| 603 | - | .with(Node::field(search)) | |
| 604 | - | .with(Node::field( | |
| 605 | - | Field::select("status", "Status", statuses) | |
| 606 | - | .value(view.status.clone().unwrap_or_default()) | |
| 607 | - | .consulting(Consult::at_once(narrowed.without_status().panel(slug))), | |
| 608 | - | )) | |
| 609 | - | .with(Node::field( | |
| 610 | - | Field::select("type", "Type", types) | |
| 611 | - | .value(view.kind.clone().unwrap_or_default()) | |
| 612 | - | .consulting(Consult::at_once(narrowed.without_kind().panel(slug))), | |
| 613 | - | )) | |
| 648 | + | kinds | |
| 614 | 649 | } | |
| 615 | 650 | ||
| 616 | - | /// The controls over the selection. | |
| 651 | + | declare! { | |
| 652 | + | /// The three narrowing controls. | |
| 653 | + | /// | |
| 654 | + | /// `N12`'s decision in one declaration: each is a control with a route, and | |
| 655 | + | /// the answer is the panel under the new view. All three ask rather than | |
| 656 | + | /// write -- narrowing a listing puts nothing anywhere -- and the difference | |
| 657 | + | /// between a value still being written and one chosen in a single gesture | |
| 658 | + | /// is the wait: the search box takes the typing wait and the two pickers | |
| 659 | + | /// ask at once. `aeb44860`, which is where the pickers stopped claiming to | |
| 660 | + | /// be writes. | |
| 661 | + | /// | |
| 662 | + | /// Each control's address leaves out its own value: a field sends its value | |
| 663 | + | /// under its own name, so an address that also carried the value it was | |
| 664 | + | /// offered under would send the old one beside the new one. | |
| 665 | + | shape filters(slug: &str, items: &[ContentItem], view: &View) -> Slot; | |
| 666 | + | ||
| 667 | + | region "content-filters" as Group { | |
| 668 | + | field Text "q" "Search items" { | |
| 669 | + | placeholder "Search items..."; | |
| 670 | + | value view.query_text(); | |
| 671 | + | consulting Consult::new(view.narrowed().without_query().panel(slug)) | |
| 672 | + | .after(SEARCH_WAIT) | |
| 673 | + | .at_least(SEARCH_FLOOR); | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | field Select "status" "Status" { | |
| 677 | + | option Choice::new("", "All statuses"); | |
| 678 | + | for status in STATUSES { | |
| 679 | + | option Choice::new(status, status); | |
| 680 | + | } | |
| 681 | + | value view.status_text(); | |
| 682 | + | consulting Consult::at_once(view.narrowed().without_status().panel(slug)); | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | field Select "type" "Type" { | |
| 686 | + | option Choice::new("", "All types"); | |
| 687 | + | for kind in kinds(items, view) { | |
| 688 | + | option Choice::new(kind.clone(), kind); | |
| 689 | + | } | |
| 690 | + | value view.kind_text(); | |
| 691 | + | consulting Consult::at_once(view.narrowed().without_kind().panel(slug)); | |
| 692 | + | } | |
| 693 | + | } | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | declare! { | |
| 697 | + | /// The controls over the selection. | |
| 698 | + | /// | |
| 699 | + | /// The shipped bar's five verbs, and its two halves are the two shapes a | |
| 700 | + | /// selection control has: Publish, Unpublish and Delete act on the set, | |
| 701 | + | /// while Set Price and Add Tag apply a value to it. The second pair is | |
| 702 | + | /// `asking`, which is the member `033ff3ca` grew off this exact bar. | |
| 703 | + | /// | |
| 704 | + | /// What is not here is the count and the disabling, for `Act::over`'s | |
| 705 | + | /// stated reason: the ticks are the host's until something submits them. | |
| 706 | + | shape bulk(slug: &str, view: &View) -> Slot; | |
| 707 | + | ||
| 708 | + | region "content-bulk" as Group { | |
| 709 | + | act "Publish" to doing view.write(slug, "bulk/publish") { | |
| 710 | + | over SELECTION; | |
| 711 | + | } | |
| 712 | + | act "Unpublish" to doing view.write(slug, "bulk/unpublish") { | |
| 713 | + | over SELECTION; | |
| 714 | + | } | |
| 715 | + | act "Set Price" to doing view.write(slug, "bulk/price") { | |
| 716 | + | over SELECTION; | |
| 717 | + | asking Field::new(layout::FieldKind::Number, "price_dollars", "New price") | |
| 718 | + | .unit("USD") | |
| 719 | + | .hint("Enter 0 to make items free."); | |
| 720 | + | } | |
| 721 | + | act "Add Tag" to doing view.write(slug, "bulk/tag") { | |
| 722 | + | over SELECTION; | |
| 723 | + | asking Field::new(layout::FieldKind::Text, "tag_slug", "Tag slug") | |
| 724 | + | .placeholder("e.g. audio.genre.ambient") | |
| 725 | + | .hint("Use dot-notation: type.category.value"); | |
| 726 | + | } | |
| 727 | + | act "Delete" to doing view.write(slug, "bulk/delete") { | |
| 728 | + | over SELECTION; | |
| 729 | + | tone Danger; | |
| 730 | + | // The JS listed the titles in a `confirm()`. A description carries | |
| 731 | + | // no list of what is ticked -- it does not know -- so the question | |
| 732 | + | // is the one every host can ask. | |
| 733 | + | confirm "Delete every selected item? This cannot be undone."; | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | // Select-all is an address. Its opposite is the same address without | |
| 737 | + | // it, and is only offered when there is something to clear. | |
| 738 | + | act "Select all" to doing view.all_ticked().panel(slug); | |
| 739 | + | act "Deselect" to doing view.narrowed().panel(slug) when view.ticked; | |
| 740 | + | } | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | declare! { | |
| 744 | + | /// The items table, with each open bundle's children under it. | |
| 745 | + | /// | |
| 746 | + | /// The columns are declared here and the cells are built two shapes away, | |
| 747 | + | /// so the cells name their columns. `#` is the project's own order and is | |
| 748 | + | /// what the reorder arrows change, so it is not sortable: ordering by it | |
| 749 | + | /// and then pressing an arrow would move a row against an order the reader | |
| 750 | + | /// cannot see. | |
| 751 | + | /// | |
| 752 | + | /// No `more`: the handler reads the project's whole catalogue and the table | |
| 753 | + | /// shows what the filters left. | |
| 754 | + | shape table(slug: &str, view: &View, shown: &[&ContentItem]) -> Node; | |
| 755 | + | ||
| 756 | + | table { | |
| 757 | + | column "#" { | |
| 758 | + | width Content; | |
| 759 | + | } | |
| 760 | + | for heading in SORTABLE { | |
| 761 | + | column heading.name { | |
| 762 | + | width heading.width; | |
| 763 | + | priority heading.priority; | |
| 764 | + | reorder view.sorted_by(heading.sort).panel(slug); | |
| 765 | + | sorted view.direction() when view.orders_by(heading.sort); | |
| 766 | + | } | |
| 767 | + | } | |
| 768 | + | column "Actions" { | |
| 769 | + | width Content; | |
| 770 | + | priority Essential; | |
| 771 | + | } | |
| 772 | + | ||
| 773 | + | for row in numbered(view, shown) { | |
| 774 | + | include item_row(slug, view, &row); | |
| 775 | + | for child in row.children() { | |
| 776 | + | include child_row(view, child); | |
| 777 | + | } | |
| 778 | + | } | |
| 779 | + | } | |
| 780 | + | } | |
| 781 | + | ||
| 782 | + | /// One item of the table, knowing where it sits in it. | |
| 617 | 783 | /// | |
| 618 | - | /// The shipped bar's five verbs, and its two halves are the two shapes a | |
| 619 | - | /// selection control has: Publish, Unpublish and Delete act on the set, while | |
| 620 | - | /// Set Price and Add Tag apply a value to it. The second pair is | |
| 621 | - | /// [`Act::asking`], which is the member `033ff3ca` grew off this exact bar. | |
| 622 | - | /// | |
| 623 | - | /// What is not here is the count and the disabling, for [`Act::over`]'s stated | |
| 624 | - | /// reason: the ticks are the host's until something submits them. | |
| 625 | - | fn bulk(slug: &str, view: &View) -> Slot { | |
| 626 | - | let mut slot = Slot::new("content-bulk", RegionKind::Group) | |
| 627 | - | .with(Node::Act( | |
| 628 | - | Act::new("Publish", view.write(slug, "bulk/publish")).over(SELECTION), | |
| 629 | - | )) | |
| 630 | - | .with(Node::Act( | |
| 631 | - | Act::new("Unpublish", view.write(slug, "bulk/unpublish")).over(SELECTION), | |
| 632 | - | )) | |
| 633 | - | .with(Node::Act( | |
| 634 | - | Act::new("Set Price", view.write(slug, "bulk/price")) | |
| 635 | - | .over(SELECTION) | |
| 636 | - | .asking( | |
| 637 | - | Field::new(layout::FieldKind::Number, "price_dollars", "New price") | |
| 638 | - | .unit("USD") | |
| 639 | - | .hint("Enter 0 to make items free."), | |
| 640 | - | ), | |
| 641 | - | )) | |
| 642 | - | .with(Node::Act( | |
| 643 | - | Act::new("Add Tag", view.write(slug, "bulk/tag")) | |
| 644 | - | .over(SELECTION) | |
| 645 | - | .asking( | |
| 646 | - | ghost( | |
| 647 | - | Field::new(layout::FieldKind::Text, "tag_slug", "Tag slug"), | |
| 648 | - | "e.g. audio.genre.ambient", | |
| 649 | - | ) | |
| 650 | - | .hint("Use dot-notation: type.category.value"), |
Lines truncated
| @@ -238,7 +238,7 @@ | |||
| 238 | 238 | .into_iter() | |
| 239 | 239 | .map(|r| { | |
| 240 | 240 | let url = format!("/git/{}/{}", db_user.username, r.name); | |
| 241 | - | (r.name, url) | |
| 241 | + | crate::quasi::project::LinkedRepo { name: r.name, url } | |
| 242 | 242 | }) | |
| 243 | 243 | .collect() | |
| 244 | 244 | } else { |