max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
6 files changed,
+1174 insertions,
-1063 deletions
| @@ -32,6 +32,17 @@ | |||
| 32 | 32 | //! described route. Their POSTs are answered by the handlers that always | |
| 33 | 33 | //! answered them, into the region the description names. | |
| 34 | 34 | //! | |
| 35 | + | //! # Why the four screens each write the same three settings | |
| 36 | + | //! | |
| 37 | + | //! `measured`, `documented` and `indexed false` are the same on all four, and | |
| 38 | + | //! they used to be a `page(title, body)` helper. R2 makes a `-> Screen` shape | |
| 39 | + | //! its single `screen`, so a helper that takes a document and hands it back is | |
| 40 | + | //! the shape `custom_page::strip` was refused for, and the settings are written | |
| 41 | + | //! out. The one worth reading once is the third: none of the four wants to be | |
| 42 | + | //! indexed, because three of them answer only to a reader holding a link or a | |
| 43 | + | //! pending session, and a login form in a search result is a phishing lure with | |
| 44 | + | //! our name on it. | |
| 45 | + | //! | |
| 35 | 46 | //! # What each form's answer lands in | |
| 36 | 47 | //! | |
| 37 | 48 | //! A named region, through [`Action::replacing`], which is documented as the | |
| @@ -59,7 +70,7 @@ | |||
| 59 | 70 | //! reader submits rather than after. | |
| 60 | 71 | ||
| 61 | 72 | use makeover_layout as layout; | |
| 62 | - | use quasi_router::screen::Field; | |
| 73 | + | use quasi_declare::declare; | |
| 63 | 74 | use quasi_router::{Action, Document, Node, RegionKind, Screen as Described, Slot}; | |
| 64 | 75 | use quasi_webview::Webview; | |
| 65 | 76 | ||
| @@ -178,233 +189,220 @@ | |||
| 178 | 189 | Webview::new().fragment(&Node::Region(slot)) | |
| 179 | 190 | } | |
| 180 | 191 | ||
| 181 | - | /// The page body every one of these four shares: one region, at the measure | |
| 182 | - | /// they all run at, carrying whatever the page put in it. | |
| 183 | - | fn page(title: &str, body: Slot) -> Described { | |
| 184 | - | Described::single(title) | |
| 185 | - | .measured(MEASURE) | |
| 186 | - | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 187 | - | // None of the four wants to be indexed: three of them answer only to a | |
| 188 | - | // reader holding a link or a pending session, and a login form in a | |
| 189 | - | // search result is a phishing lure with our name on it. | |
| 190 | - | .indexed(false) | |
| 191 | - | .with(body) | |
| 192 | - | } | |
| 192 | + | declare! { | |
| 193 | + | /// An empty region for a route's answer to land in. | |
| 194 | + | /// | |
| 195 | + | /// Empty, and that is the point: it is an address rather than content. The | |
| 196 | + | /// error a full-page POST re-renders goes in through `error` instead, | |
| 197 | + | /// because on that path there is no swap to land anything. | |
| 198 | + | /// | |
| 199 | + | /// An `Option` is an iterator of at most one, and `.into_iter()` is the | |
| 200 | + | /// method step that says so. | |
| 201 | + | shape feedback(id: &str, error: Option<&str>) -> Node; | |
| 193 | 202 | ||
| 194 | - | /// An empty region for a route's answer to land in. | |
| 195 | - | /// | |
| 196 | - | /// Empty, and that is the point: it is an address rather than content. The | |
| 197 | - | /// error a full-page POST re-renders goes in through `error` instead, because | |
| 198 | - | /// on that path there is no swap to land anything. | |
| 199 | - | fn feedback(id: &str, error: Option<&str>) -> Node { | |
| 200 | - | let mut slot = Slot::new(id, RegionKind::Pane); | |
| 201 | - | if let Some(message) = error { | |
| 202 | - | slot = slot.with(Node::banner(layout::Tone::Danger, message)); | |
| 203 | - | } | |
| 204 | - | Node::Region(slot) | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | /// The link back to the login form, which three of these four carry. | |
| 208 | - | fn back_to_login() -> Node { | |
| 209 | - | Node::Link { | |
| 210 | - | text: "Back to login".to_owned(), | |
| 211 | - | action: Action::get("/login").navigating(), | |
| 203 | + | region id as Pane { | |
| 204 | + | for message in error.into_iter() { | |
| 205 | + | banner layout::Tone::Danger message; | |
| 206 | + | } | |
| 212 | 207 | } | |
| 213 | 208 | } | |
| 214 | 209 | ||
| 215 | - | /// `/login`. | |
| 216 | - | /// | |
| 217 | - | /// `sso_enabled` is the testnot.work preview, where there is no local password | |
| 218 | - | /// at all and the whole form is replaced by one link. Two shapes of the same | |
| 219 | - | /// screen rather than two screens, because everything around them -- the | |
| 220 | - | /// wordmark, the measure, the notice -- is the same. | |
| 221 | - | #[must_use] | |
| 222 | - | pub fn login( | |
| 223 | - | prefill: &str, | |
| 224 | - | error: Option<&str>, | |
| 225 | - | notice: Option<&str>, | |
| 226 | - | sso_enabled: bool, | |
| 227 | - | ) -> Described { | |
| 228 | - | let mut body = Slot::new(PAGE_REGION, RegionKind::Pane); | |
| 210 | + | declare! { | |
| 211 | + | /// The link back to the login form, which three of these four carry. | |
| 212 | + | shape back_to_login() -> Node; | |
| 229 | 213 | ||
| 230 | - | if let Some(note) = notice { | |
| 231 | - | body = body.with(Node::banner(layout::Tone::Info, note)); | |
| 232 | - | } | |
| 214 | + | link "Back to login" to get "/login" navigating; | |
| 215 | + | } | |
| 233 | 216 | ||
| 234 | - | if sso_enabled { | |
| 235 | - | return page( | |
| 236 | - | "Log In - Makenotwork", | |
| 237 | - | body.with(feedback(LOGIN_FEEDBACK, error)) | |
| 238 | - | .with(Node::section("Log in")) | |
| 239 | - | .with(Node::text( | |
| 240 | - | "testnot.work is a preview of makenot.work. Sign in with your \ | |
| 241 | - | makenot.work account to continue. Your password is only ever \ | |
| 242 | - | entered on makenot.work.", | |
| 243 | - | )) | |
| 244 | - | .with(Node::act( | |
| 245 | - | "Sign in with Makenot.work", | |
| 246 | - | Action::get("/sso/login").navigating(), | |
| 247 | - | )), | |
| 248 | - | ); | |
| 249 | - | } | |
| 217 | + | declare! { | |
| 218 | + | /// `/login`. | |
| 219 | + | /// | |
| 220 | + | /// `sso_enabled` is the testnot.work preview, where there is no local | |
| 221 | + | /// password at all and the whole form is replaced by one link. Two shapes | |
| 222 | + | /// of the same screen rather than two screens, because everything around | |
| 223 | + | /// them -- the wordmark, the measure, the notice -- is the same. Said as | |
| 224 | + | /// guards rather than as a dispatch, because the two branches offer a | |
| 225 | + | /// different *number* of things and a dispatch arm is one emission. | |
| 226 | + | #[must_use] | |
| 227 | + | pub shape login( | |
| 228 | + | prefill: &str, | |
| 229 | + | error: Option<&str>, | |
| 230 | + | notice: Option<&str>, | |
| 231 | + | sso_enabled: bool, | |
| 232 | + | ) -> Screen; | |
| 250 | 233 | ||
| 251 | - | let mut login_field = Field::new(layout::FieldKind::Text, "login", "Username or Email") | |
| 252 | - | .required() | |
| 253 | - | // What was typed, so a wrong password does not cost the address as | |
| 254 | - | // well. `login_handler`'s full-page error path has always done this. | |
| 255 | - | .value(prefill); | |
| 256 | - | login_field.placeholder = Some("username or you@example.com".to_owned()); | |
| 234 | + | screen single "Log In - Makenotwork" { | |
| 235 | + | measured MEASURE; | |
| 236 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 237 | + | indexed false; | |
| 238 | + | // The caret in the box the reader came here to fill. The password box | |
| 239 | + | // is the wrong answer even for somebody whose browser fills the first | |
| 240 | + | // one: a filled box is still where a correction is made. The preview | |
| 241 | + | // has no box at all, so it opens at nothing. | |
| 242 | + | opening_at "login" unless sso_enabled; | |
| 257 | 243 | ||
| 258 | - | let mut password = Field::new(layout::FieldKind::Secret, "password", "Password").required(); | |
| 259 | - | password.placeholder = Some("--------".to_owned()); | |
| 244 | + | region PAGE_REGION as Pane { | |
| 245 | + | for note in notice.into_iter() { | |
| 246 | + | banner layout::Tone::Info note; | |
| 247 | + | } | |
| 248 | + | include feedback(LOGIN_FEEDBACK, error); | |
| 249 | + | section "Log in"; | |
| 260 | 250 | ||
| 261 | - | page( | |
| 262 | - | "Log In - Makenotwork", | |
| 263 | - | body.with(feedback(LOGIN_FEEDBACK, error)) | |
| 264 | - | .with(Node::section("Log in")) | |
| 265 | - | .with(Node::Form { | |
| 266 | - | action: Action::post("/login").replacing(LOGIN_FEEDBACK), | |
| 267 | - | submit: "Log In".to_owned(), | |
| 268 | - | fields: vec![ | |
| 269 | - | login_field, | |
| 270 | - | password, | |
| 271 | - | Field::new(layout::FieldKind::Checkbox, "remember_me", "Remember me"), | |
| 272 | - | ], | |
| 273 | - | }) | |
| 274 | - | .with(Node::Link { | |
| 275 | - | text: "Reset Password".to_owned(), | |
| 276 | - | action: Action::get("/forgot-password").navigating(), | |
| 277 | - | }) | |
| 278 | - | .with(Node::Link { | |
| 279 | - | text: "Join now".to_owned(), | |
| 280 | - | action: Action::get("/join").navigating(), | |
| 281 | - | }) | |
| 251 | + | text "testnot.work is a preview of makenot.work. Sign in with your \ | |
| 252 | + | makenot.work account to continue. Your password is only ever \ | |
| 253 | + | entered on makenot.work." | |
| 254 | + | when sso_enabled; | |
| 255 | + | act "Sign in with Makenot.work" to get "/sso/login" navigating when sso_enabled; | |
| 256 | + | ||
| 257 | + | form post "/login" replacing LOGIN_FEEDBACK unless sso_enabled { | |
| 258 | + | submit "Log In"; | |
| 259 | + | field Text "login" "Username or Email" { | |
| 260 | + | required; | |
| 261 | + | // What was typed, so a wrong password does not cost the | |
| 262 | + | // address as well. `login_handler`'s full-page error path | |
| 263 | + | // has always done this. | |
| 264 | + | value prefill; | |
| 265 | + | placeholder "username or you@example.com"; | |
| 266 | + | } | |
| 267 | + | field Secret "password" "Password" { | |
| 268 | + | required; | |
| 269 | + | placeholder "--------"; | |
| 270 | + | } | |
| 271 | + | field Checkbox "remember_me" "Remember me"; | |
| 272 | + | } | |
| 273 | + | link "Reset Password" to get "/forgot-password" navigating unless sso_enabled; | |
| 274 | + | link "Join now" to get "/join" navigating unless sso_enabled; | |
| 282 | 275 | // Hidden until `page-login-2.js` has asked the browser whether it | |
| 283 | 276 | // can do WebAuthn. See `PASSKEY`. | |
| 284 | - | .with(Node::Region(Slot::handover( | |
| 285 | - | PASSKEY_REGION, | |
| 286 | - | "the passkey offer", | |
| 287 | - | ))), | |
| 288 | - | ) | |
| 289 | - | // The caret in the box the reader came here to fill. The password box is | |
| 290 | - | // the wrong answer even for somebody whose browser fills the first one: | |
| 291 | - | // a filled box is still where a correction is made. | |
| 292 | - | .opening_at("login") | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | /// `/forgot-password`. | |
| 296 | - | #[must_use] | |
| 297 | - | pub fn forgot_password() -> Described { | |
| 298 | - | let mut email = Field::new(layout::FieldKind::Email, "email", "Email").required(); | |
| 299 | - | email.placeholder = Some("you@example.com".to_owned()); | |
| 300 | - | ||
| 301 | - | page( | |
| 302 | - | "Reset Password - Makenotwork", | |
| 303 | - | Slot::new(PAGE_REGION, RegionKind::Pane) | |
| 304 | - | .with(feedback(FEEDBACK, None)) | |
| 305 | - | .with(Node::section("Reset Password")) | |
| 306 | - | .with(Node::text( | |
| 307 | - | "Enter your email address and we'll send you a link to reset your password.", | |
| 308 | - | )) | |
| 309 | - | .with(Node::Form { | |
| 310 | - | action: Action::post("/forgot-password").replacing(FEEDBACK), | |
| 311 | - | submit: "Send Reset Link".to_owned(), | |
| 312 | - | fields: vec![email], | |
| 313 | - | }) | |
| 314 | - | .with(back_to_login()), | |
| 315 | - | ) | |
| 316 | - | .opening_at("email") | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | /// `/reset-password`. | |
| 320 | - | /// | |
| 321 | - | /// `valid` is whether the signed link still resolves. An expired one is a | |
| 322 | - | /// different screen rather than a disabled form: there is nothing to type, and | |
| 323 | - | /// the only useful control is the one that asks for a fresh link. | |
| 324 | - | /// | |
| 325 | - | /// The token rides on the action as a parameter rather than as a hidden field. | |
| 326 | - | /// A hidden input is markup standing in for a value the call already carries, | |
| 327 | - | /// and [`Action::with`] is what the vocabulary has for "send this along". | |
| 328 | - | #[must_use] | |
| 329 | - | pub fn reset_password(valid: bool, token: &str, error: Option<&str>) -> Described { | |
| 330 | - | let mut body = Slot::new(PAGE_REGION, RegionKind::Pane).with(feedback(FEEDBACK, error)); | |
| 331 | - | ||
| 332 | - | if valid { | |
| 333 | - | let mut password = Field::new(layout::FieldKind::Secret, "password", "New Password") | |
| 334 | - | .required() | |
| 335 | - | // The rule the server enforces, said before the reader submits | |
| 336 | - | // rather than after. See the module header for why it is not | |
| 337 | - | // `minlength`. | |
| 338 | - | .hint("At least 8 characters"); | |
| 339 | - | password.placeholder = Some("--------".to_owned()); | |
| 340 | - | ||
| 341 | - | let mut confirm = Field::new( | |
| 342 | - | layout::FieldKind::Secret, | |
| 343 | - | "password_confirm", | |
| 344 | - | "Confirm Password", | |
| 345 | - | ) | |
| 346 | - | .required(); | |
| 347 | - | confirm.placeholder = Some("--------".to_owned()); | |
| 348 | - | ||
| 349 | - | body = body | |
| 350 | - | .with(Node::section("Set New Password")) | |
| 351 | - | .with(Node::text("Enter your new password below.")) | |
| 352 | - | .with(Node::Form { | |
| 353 | - | action: Action::post("/reset-password") | |
| 354 | - | .with("token", token) | |
| 355 | - | .replacing(FEEDBACK), | |
| 356 | - | submit: "Set Password".to_owned(), | |
| 357 | - | fields: vec![password, confirm], | |
| 358 | - | }); | |
| 359 | - | } else { | |
| 360 | - | body = body | |
| 361 | - | .with(Node::section("Link Expired")) | |
| 362 | - | .with(Node::text( | |
| 363 | - | "This password reset link has expired or is invalid. Please request a new one.", | |
| 364 | - | )) | |
| 365 | - | .with(Node::act( | |
| 366 | - | "Request New Link", | |
| 367 | - | Action::get("/forgot-password").navigating(), | |
| 368 | - | )); | |
| 369 | - | } | |
| 370 | - | ||
| 371 | - | let screen = page("Set New Password - Makenotwork", body.with(back_to_login())); | |
| 372 | - | if valid { | |
| 373 | - | screen.opening_at("password") | |
| 374 | - | } else { | |
| 375 | - | // Nothing to type into, so nothing to open at. A name no field carries | |
| 376 | - | // would be honoured as nothing anyway; saying nothing is the honest | |
| 377 | - | // spelling of it. | |
| 378 | - | screen | |
| 277 | + | region PASSKEY_REGION as RegionKind::handover("the passkey offer") | |
| 278 | + | unless sso_enabled {} | |
| 279 | + | } | |
| 379 | 280 | } | |
| 380 | 281 | } | |
| 381 | 282 | ||
| 382 | - | /// `/auth/2fa`. | |
| 383 | - | #[must_use] | |
| 384 | - | pub fn two_factor(error: Option<&str>) -> Described { | |
| 385 | - | let mut code = Field::new(layout::FieldKind::Text, "code", "Verification Code").required(); | |
| 386 | - | code.placeholder = Some("000000".to_owned()); | |
| 387 | - | // Six digits or an eight-character backup code, which is what the template | |
| 388 | - | // capped it at. A cap the box enforces, unlike the password minimum below: | |
| 389 | - | // `Field` carries a maximum and no minimum. | |
| 390 | - | code.max_length = Some(8); | |
| 283 | + | declare! { | |
| 284 | + | /// `/forgot-password`. | |
| 285 | + | #[must_use] | |
| 286 | + | pub shape forgot_password() -> Screen; | |
| 391 | 287 | ||
| 392 | - | page( | |
| 393 | - | "Two-Factor Authentication - Makenotwork", | |
| 394 | - | Slot::new(PAGE_REGION, RegionKind::Pane) | |
| 395 | - | .with(feedback(FEEDBACK, error)) | |
| 396 | - | .with(Node::section("Two-Factor Authentication")) | |
| 397 | - | .with(Node::text( | |
| 398 | - | "Enter the 6-digit code from your authenticator app, or use a backup code.", | |
| 399 | - | )) | |
| 400 | - | .with(Node::Form { | |
| 401 | - | action: Action::post("/auth/verify-2fa").replacing(FEEDBACK), | |
| 402 | - | submit: "Verify".to_owned(), | |
| 403 | - | fields: vec![code], | |
| 404 | - | }) | |
| 405 | - | .with(back_to_login()), | |
| 406 | - | ) | |
| 407 | - | .opening_at("code") | |
| 288 | + | screen single "Reset Password - Makenotwork" { | |
| 289 | + | measured MEASURE; | |
| 290 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 291 | + | indexed false; | |
| 292 | + | opening_at "email"; | |
| 293 | + | ||
| 294 | + | region PAGE_REGION as Pane { | |
| 295 | + | include feedback(FEEDBACK, None); | |
| 296 | + | section "Reset Password"; | |
| 297 | + | text "Enter your email address and we'll send you a link to reset your password."; | |
| 298 | + | form post "/forgot-password" replacing FEEDBACK { | |
| 299 | + | submit "Send Reset Link"; | |
| 300 | + | field Email "email" "Email" { | |
| 301 | + | required; | |
| 302 | + | placeholder "you@example.com"; | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | include back_to_login(); | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /// Where a new password is sent, with the signed token along for the ride. | |
| 311 | + | /// | |
| 312 | + | /// A supplier because `Action::with(name, value)` is refused as a production: a | |
| 313 | + | /// form has no way to say "and this value too, sometimes", and `doing` is the | |
| 314 | + | /// remedy the form already names for an action it cannot spell. Counted in the | |
| 315 | + | /// population for the reason `tip::checkout` is. | |
| 316 | + | fn reset_action(token: &str) -> Action { | |
| 317 | + | Action::post("/reset-password") | |
| 318 | + | .with("token", token) | |
| 319 | + | .replacing(FEEDBACK) | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | declare! { | |
| 323 | + | /// `/reset-password`. | |
| 324 | + | /// | |
| 325 | + | /// `valid` is whether the signed link still resolves. An expired one is a | |
| 326 | + | /// different screen rather than a disabled form: there is nothing to type, | |
| 327 | + | /// and the only useful control is the one that asks for a fresh link. | |
| 328 | + | /// | |
| 329 | + | /// The token rides on the action as a parameter rather than as a hidden | |
| 330 | + | /// field. A hidden input is markup standing in for a value the call already | |
| 331 | + | /// carries. See [`reset_action`]. | |
| 332 | + | #[must_use] | |
| 333 | + | pub shape reset_password(valid: bool, token: &str, error: Option<&str>) -> Screen; | |
| 334 | + | ||
| 335 | + | screen single "Set New Password - Makenotwork" { | |
| 336 | + | measured MEASURE; | |
| 337 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 338 | + | indexed false; | |
| 339 | + | // Nothing to type into on the expired screen, so nothing to open at. A | |
| 340 | + | // name no field carries would be honoured as nothing anyway; saying | |
| 341 | + | // nothing is the honest spelling of it. | |
| 342 | + | opening_at "password" when valid; | |
| 343 | + | ||
| 344 | + | region PAGE_REGION as Pane { | |
| 345 | + | include feedback(FEEDBACK, error); | |
| 346 | + | ||
| 347 | + | section "Set New Password" when valid; | |
| 348 | + | text "Enter your new password below." when valid; | |
| 349 | + | form doing reset_action(token) when valid { | |
| 350 | + | submit "Set Password"; | |
| 351 | + | field Secret "password" "New Password" { | |
| 352 | + | required; | |
| 353 | + | // The rule the server enforces, said before the reader | |
| 354 | + | // submits rather than after. See the module header for why | |
| 355 | + | // it is not `minlength`. | |
| 356 | + | hint "At least 8 characters"; | |
| 357 | + | placeholder "--------"; | |
| 358 | + | } | |
| 359 | + | field Secret "password_confirm" "Confirm Password" { | |
| 360 | + | required; | |
| 361 | + | placeholder "--------"; | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | section "Link Expired" unless valid; | |
| 366 | + | text "This password reset link has expired or is invalid. Please request a \ | |
| 367 | + | new one." | |
| 368 | + | unless valid; | |
| 369 | + | act "Request New Link" to get "/forgot-password" navigating unless valid; | |
| 370 | + | ||
| 371 | + | include back_to_login(); | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | declare! { | |
| 377 | + | /// `/auth/2fa`. | |
| 378 | + | #[must_use] | |
| 379 | + | pub shape two_factor(error: Option<&str>) -> Screen; | |
| 380 | + | ||
| 381 | + | screen single "Two-Factor Authentication - Makenotwork" { | |
| 382 | + | measured MEASURE; | |
| 383 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 384 | + | indexed false; | |
| 385 | + | opening_at "code"; | |
| 386 | + | ||
| 387 | + | region PAGE_REGION as Pane { | |
| 388 | + | include feedback(FEEDBACK, error); | |
| 389 | + | section "Two-Factor Authentication"; | |
| 390 | + | text "Enter the 6-digit code from your authenticator app, or use a backup code."; | |
| 391 | + | form post "/auth/verify-2fa" replacing FEEDBACK { | |
| 392 | + | submit "Verify"; | |
| 393 | + | field Text "code" "Verification Code" { | |
| 394 | + | required; | |
| 395 | + | placeholder "000000"; | |
| 396 | + | // Six digits or an eight-character backup code, which is | |
| 397 | + | // what the template capped it at. A cap the box enforces, | |
| 398 | + | // unlike the password minimum above: `Field` carries a | |
| 399 | + | // maximum and no minimum. | |
| 400 | + | limited_to 8; | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | include back_to_login(); | |
| 404 | + | } | |
| 405 | + | } | |
| 408 | 406 | } | |
| 409 | 407 | ||
| 410 | 408 | #[cfg(test)] |
| @@ -19,8 +19,9 @@ | |||
| 19 | 19 | //! gap open while the page was still a template; it is gone with the template. | |
| 20 | 20 | ||
| 21 | 21 | use makeover_layout as layout; | |
| 22 | - | use quasi_router::screen::{Cell, Cells, Column, Lexeme, Table}; | |
| 23 | - | use quasi_router::{Action, Document, Node, RegionKind, Run, Screen, Slot}; | |
| 22 | + | use quasi_declare::declare; | |
| 23 | + | use quasi_router::Document; | |
| 24 | + | use quasi_router::screen::Lexeme; | |
| 24 | 25 | use quasi_webview::Webview; | |
| 25 | 26 | ||
| 26 | 27 | use crate::git::{Breadcrumb, RefInfo, TreeItem, TreeItemKind}; | |
| @@ -65,19 +66,6 @@ | |||
| 65 | 66 | refs: self.refs, | |
| 66 | 67 | } | |
| 67 | 68 | } | |
| 68 | - | ||
| 69 | - | /// The identity line, the bar and the trail, which both pages open with. | |
| 70 | - | fn opening(&self) -> Slot { | |
| 71 | - | Slot::new(PAGE_REGION, RegionKind::Pane) | |
| 72 | - | .with(super::widgets::git_nav::heading(self.owner, self.repo)) | |
| 73 | - | .with(super::widgets::git_nav::region(&self.nav())) | |
| 74 | - | .with(super::widgets::git_nav::breadcrumb( | |
| 75 | - | self.owner, | |
| 76 | - | self.repo, | |
| 77 | - | self.current_ref, | |
| 78 | - | self.breadcrumbs, | |
| 79 | - | )) | |
| 80 | - | } | |
| 81 | 69 | } | |
| 82 | 70 | ||
| 83 | 71 | /// A directory listing. | |
| @@ -105,214 +93,255 @@ | |||
| 105 | 93 | pub notes: &'a [CommitNote], | |
| 106 | 94 | } | |
| 107 | 95 | ||
| 108 | - | /// The directory listing, as a screen. | |
| 109 | - | #[must_use] | |
| 110 | - | pub fn tree_screen(frame: &Frame<'_>, tree: &Tree<'_>) -> Screen { | |
| 111 | - | let page = frame.opening().with(listing(frame, tree)); | |
| 96 | + | declare! { | |
| 97 | + | /// The identity line, the bar and the trail, which both pages open with. | |
| 98 | + | /// | |
| 99 | + | /// A panel's members rather than the region itself, because both screens | |
| 100 | + | /// then add their own content to the same region and a shape that takes a | |
| 101 | + | /// container and hands it back is what `custom_page::strip` was refused | |
| 102 | + | /// for. `-> Vec<Node>` is the shape for exactly this: members in order, | |
| 103 | + | /// with nothing wrapping them. | |
| 104 | + | shape opening(frame: &Frame<'_>) -> Vec<Node>; | |
| 112 | 105 | ||
| 113 | - | Screen::single(format!( | |
| 114 | - | "{} - {} - Git - Makenotwork", | |
| 115 | - | frame.path, frame.repo | |
| 116 | - | )) | |
| 117 | - | .measured(MEASURE) | |
| 118 | - | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 119 | - | .with(page) | |
| 106 | + | include super::widgets::git_nav::heading(frame.owner, frame.repo); | |
| 107 | + | include super::widgets::git_nav::region(&frame.nav()); | |
| 108 | + | include super::widgets::git_nav::breadcrumb( | |
| 109 | + | frame.owner, | |
| 110 | + | frame.repo, | |
| 111 | + | frame.current_ref, | |
| 112 | + | frame.breadcrumbs | |
| 113 | + | ); | |
| 120 | 114 | } | |
| 121 | 115 | ||
| 122 | - | /// The file view, as a screen. | |
| 123 | - | #[must_use] | |
| 124 | - | pub fn file_screen(frame: &Frame<'_>, file: &File<'_>) -> Screen { | |
| 125 | - | let mut page = frame.opening().with(file_header(frame, file)); | |
| 116 | + | declare! { | |
| 117 | + | /// The directory listing, as a screen. | |
| 118 | + | #[must_use] | |
| 119 | + | pub shape tree_screen(frame: &Frame<'_>, tree: &Tree<'_>) -> Screen; | |
| 126 | 120 | ||
| 127 | - | if let Some(notes) = super::widgets::git_notes::region(file.notes) { | |
| 128 | - | page = page.with(notes); | |
| 121 | + | screen single "{frame.path} - {frame.repo} - Git - Makenotwork" { | |
| 122 | + | measured MEASURE; | |
| 123 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 124 | + | ||
| 125 | + | region PAGE_REGION as Pane { | |
| 126 | + | for node in opening(frame) { | |
| 127 | + | include node; | |
| 128 | + | } | |
| 129 | + | include listing(frame, tree); | |
| 130 | + | } | |
| 129 | 131 | } | |
| 130 | - | ||
| 131 | - | page = page.with(if file.is_binary { | |
| 132 | - | binary(frame, file) | |
| 133 | - | } else { | |
| 134 | - | source(file) | |
| 135 | - | }); | |
| 136 | - | ||
| 137 | - | Screen::single(format!( | |
| 138 | - | "{} - {} - Git - Makenotwork", | |
| 139 | - | file.filename, frame.repo | |
| 140 | - | )) | |
| 141 | - | .measured(MEASURE) | |
| 142 | - | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 143 | - | .with(page) | |
| 144 | 132 | } | |
| 145 | 133 | ||
| 146 | - | /// What is in this directory. | |
| 134 | + | declare! { | |
| 135 | + | /// The file view, as a screen. | |
| 136 | + | /// | |
| 137 | + | /// The notes region is an `-> Option<_>` shape, and an `Option` is an | |
| 138 | + | /// iterator of at most one: `.into_iter()` is the method step that says so | |
| 139 | + | /// rather than a production for placing an absence. | |
| 140 | + | #[must_use] | |
| 141 | + | pub shape file_screen(frame: &Frame<'_>, file: &File<'_>) -> Screen; | |
| 142 | + | ||
| 143 | + | screen single "{file.filename} - {frame.repo} - Git - Makenotwork" { | |
| 144 | + | measured MEASURE; | |
| 145 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 146 | + | ||
| 147 | + | region PAGE_REGION as Pane { | |
| 148 | + | for node in opening(frame) { | |
| 149 | + | include node; | |
| 150 | + | } | |
| 151 | + | include file_header(frame, file); | |
| 152 | + | for notes in super::widgets::git_notes::region(file.notes).into_iter() { | |
| 153 | + | include notes; | |
| 154 | + | } | |
| 155 | + | given file.is_binary { | |
| 156 | + | true -> include binary(frame, file); | |
| 157 | + | otherwise -> include source(file); | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /// What an entry is called in the listing. | |
| 147 | 164 | /// | |
| 148 | - | /// `..` is a row like any other, which is what the shipped table made it: it is | |
| 149 | - | /// a place in the tree, and giving it its own control would say it was a | |
| 150 | - | /// different kind of thing. | |
| 151 | - | fn listing(frame: &Frame<'_>, tree: &Tree<'_>) -> Node { | |
| 152 | - | let mut rows = Vec::with_capacity(tree.items.len() + 1); | |
| 153 | - | ||
| 154 | - | // Rows are built here and the columns are declared at the bottom of the | |
| 155 | - | // function, so the cells name their columns: `..` and an entry are two | |
| 156 | - | // separate constructions that would otherwise have to agree with a heading | |
| 157 | - | // list neither of them can see. | |
| 158 | - | if let Some(parent) = tree.parent { | |
| 159 | - | rows.push( | |
| 160 | - | Cells::default() | |
| 161 | - | .at("Name", Cell::new("..")) | |
| 162 | - | .at("Size", Cell::new(String::new())) | |
| 163 | - | .activate(Action::get(frame.tree(parent)).navigating()), | |
| 164 | - | ); | |
| 165 | + | /// A trailing slash is how a directory says it is one. The shipped table said | |
| 166 | + | /// it twice -- an icon column holding `/` and the slash on the name -- and one | |
| 167 | + | /// of the two was a column of one character that carried no fact the name did | |
| 168 | + | /// not. | |
| 169 | + | fn entry_name(item: &TreeItem) -> String { | |
| 170 | + | match item.kind { | |
| 171 | + | TreeItemKind::Dir => format!("{}/", item.name), | |
| 172 | + | TreeItemKind::File => item.name.clone(), | |
| 165 | 173 | } | |
| 166 | - | ||
| 167 | - | for item in tree.items { | |
| 168 | - | // A trailing slash is how a directory says it is one. The shipped table | |
| 169 | - | // said it twice -- an icon column holding `/` and the slash on the name | |
| 170 | - | // -- and one of the two was a column of one character that carried no | |
| 171 | - | // fact the name did not. | |
| 172 | - | let name = match item.kind { | |
| 173 | - | TreeItemKind::Dir => format!("{}/", item.name), | |
| 174 | - | TreeItemKind::File => item.name.clone(), | |
| 175 | - | }; | |
| 176 | - | let path = if frame.path.is_empty() { | |
| 177 | - | item.name.clone() | |
| 178 | - | } else { | |
| 179 | - | format!("{}/{}", frame.path, item.name) | |
| 180 | - | }; | |
| 181 | - | ||
| 182 | - | rows.push( | |
| 183 | - | Cells::default() | |
| 184 | - | .at("Name", Cell::new(name)) | |
| 185 | - | .at( | |
| 186 | - | "Size", | |
| 187 | - | Cell::new( | |
| 188 | - | item.size | |
| 189 | - | .as_ref() | |
| 190 | - | .map(crate::routes::git::format_size) | |
| 191 | - | .unwrap_or_default(), | |
| 192 | - | ), | |
| 193 | - | ) | |
| 194 | - | .activate(Action::get(frame.tree(&path)).navigating()), | |
| 195 | - | ); | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | Table::new(vec![ | |
| 199 | - | Column::new("Name") | |
| 200 | - | .width(layout::Width::Fill) | |
| 201 | - | .priority(layout::Priority::Essential), | |
| 202 | - | Column::new("Size") | |
| 203 | - | .width(layout::Width::Content) | |
| 204 | - | .priority(layout::Priority::Secondary), | |
| 205 | - | ]) | |
| 206 | - | .rows(rows) | |
| 207 | - | .into() | |
| 208 | 174 | } | |
| 209 | 175 | ||
| 210 | - | /// The strip over a file: how big it is, and the other three ways to read it. | |
| 211 | - | fn file_header(frame: &Frame<'_>, file: &File<'_>) -> Node { | |
| 212 | - | let base = frame.base(); | |
| 176 | + | /// Where it is, under the path being browsed. | |
| 177 | + | fn entry_path(frame: &Frame<'_>, item: &TreeItem) -> String { | |
| 178 | + | if frame.path.is_empty() { | |
| 179 | + | item.name.clone() | |
| 180 | + | } else { | |
| 181 | + | format!("{}/{}", frame.path, item.name) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// How big it is, or nothing for a directory. | |
| 186 | + | fn entry_size(item: &TreeItem) -> String { | |
| 187 | + | item.size | |
| 188 | + | .as_ref() | |
| 189 | + | .map(crate::routes::git::format_size) | |
| 190 | + | .unwrap_or_default() | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | declare! { | |
| 194 | + | /// What is in this directory. | |
| 195 | + | /// | |
| 196 | + | /// `..` is a row like any other, which is what the shipped table made it: | |
| 197 | + | /// it is a place in the tree, and giving it its own control would say it | |
| 198 | + | /// was a different kind of thing. It is drawn only below the root, which is | |
| 199 | + | /// where there is an up, and an `Option` is an iterator of at most one. | |
| 200 | + | /// | |
| 201 | + | /// The rows are two separate constructions and the columns are declared | |
| 202 | + | /// above them, so the cells name their columns rather than counting against | |
| 203 | + | /// a heading list neither construction can see. | |
| 204 | + | shape listing(frame: &Frame<'_>, tree: &Tree<'_>) -> Node; | |
| 205 | + | ||
| 206 | + | table { | |
| 207 | + | column "Name" { | |
| 208 | + | width Fill; | |
| 209 | + | priority Essential; | |
| 210 | + | } | |
| 211 | + | column "Size" { | |
| 212 | + | width Content; | |
| 213 | + | priority Secondary; | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | for parent in tree.parent.into_iter() { | |
| 217 | + | cells { | |
| 218 | + | cell at "Name" ".."; | |
| 219 | + | cell at "Size" ""; | |
| 220 | + | activate to get frame.tree(parent) navigating; | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | for item in tree.items.iter() { | |
| 225 | + | cells { | |
| 226 | + | cell at "Name" entry_name(item); | |
| 227 | + | cell at "Size" entry_size(item); | |
| 228 | + | activate to get frame.tree(&entry_path(frame, item)) navigating; | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// How big the file is, and how long, in the strip's one sentence. | |
| 235 | + | fn size_line(file: &File<'_>) -> String { | |
| 213 | 236 | let count = file.lines.len(); | |
| 214 | - | let meta = if file.is_binary { | |
| 237 | + | if file.is_binary { | |
| 215 | 238 | file.file_size.to_owned() | |
| 216 | 239 | } else if count == 1 { | |
| 217 | 240 | format!("{} - 1 line", file.file_size) | |
| 218 | 241 | } else { | |
| 219 | 242 | format!("{} - {count} lines", file.file_size) | |
| 220 | - | }; | |
| 221 | - | ||
| 222 | - | let mut strip = | |
| 223 | - | Run::new(layout::Fallback::Wrap).beside(Node::text(meta), layout::Priority::Secondary); | |
| 224 | - | ||
| 225 | - | for (label, route) in [ | |
| 226 | - | ( | |
| 227 | - | "History", | |
| 228 | - | format!("{base}/log/{}/{}", frame.current_ref, file.file_path), | |
| 229 | - | ), | |
| 230 | - | ( | |
| 231 | - | "Blame", | |
| 232 | - | format!("{base}/blame/{}/{}", frame.current_ref, file.file_path), | |
| 233 | - | ), | |
| 234 | - | ( | |
| 235 | - | "Raw", | |
| 236 | - | format!("{base}/raw/{}/{}", frame.current_ref, file.file_path), | |
| 237 | - | ), | |
| 238 | - | ] { | |
| 239 | - | strip = strip.beside( | |
| 240 | - | Node::Link { | |
| 241 | - | text: label.to_owned(), | |
| 242 | - | action: Action::get(route).navigating(), | |
| 243 | - | }, | |
| 244 | - | layout::Priority::Essential, | |
| 245 | - | ); | |
| 246 | 243 | } | |
| 247 | - | ||
| 248 | - | Node::Region(Slot::new("git-file-header", RegionKind::Group).across(strip)) | |
| 249 | 244 | } | |
| 250 | 245 | ||
| 251 | - | /// A file nothing can usefully draw, and the way to get it anyway. | |
| 252 | - | fn binary(frame: &Frame<'_>, file: &File<'_>) -> Node { | |
| 253 | - | Node::Region( | |
| 254 | - | Slot::new("git-binary-notice", RegionKind::Group).across( | |
| 255 | - | Run::new(layout::Fallback::Wrap) | |
| 256 | - | .beside( | |
| 257 | - | Node::text(format!("Binary file ({}).", file.file_size)), | |
| 258 | - | layout::Priority::Essential, | |
| 259 | - | ) | |
| 260 | - | .beside( | |
| 261 | - | Node::Link { | |
| 262 | - | text: "Download".to_owned(), | |
| 263 | - | action: Action::get(format!( | |
| 264 | - | "{}/raw/{}/{}", | |
| 265 | - | frame.base(), | |
| 266 | - | frame.current_ref, | |
| 267 | - | file.file_path | |
| 268 | - | )) | |
| 269 | - | .navigating(), | |
| 270 | - | }, | |
| 271 | - | layout::Priority::Essential, | |
| 272 | - | ), | |
| 273 | - | ), | |
| 274 | - | ) | |
| 246 | + | declare! { | |
| 247 | + | /// The strip over a file: how big it is, and the other three ways to read | |
| 248 | + | /// it. | |
| 249 | + | shape file_header(frame: &Frame<'_>, file: &File<'_>) -> Node; | |
| 250 | + | ||
| 251 | + | let base = frame.base(); | |
| 252 | + | ||
| 253 | + | region "git-file-header" as Group { | |
| 254 | + | across Wrap { | |
| 255 | + | beside Secondary text size_line(file); | |
| 256 | + | beside Essential link "History" | |
| 257 | + | to get "{base}/log/{frame.current_ref}/{file.file_path}" navigating; | |
| 258 | + | beside Essential link "Blame" | |
| 259 | + | to get "{base}/blame/{frame.current_ref}/{file.file_path}" navigating; | |
| 260 | + | beside Essential link "Raw" | |
| 261 | + | to get "{base}/raw/{frame.current_ref}/{file.file_path}" navigating; | |
| 262 | + | } | |
| 263 | + | } | |
| 275 | 264 | } | |
| 276 | 265 | ||
| 277 | - | /// The file, one row per line. | |
| 266 | + | declare! { | |
| 267 | + | /// A file nothing can usefully draw, and the way to get it anyway. | |
| 268 | + | shape binary(frame: &Frame<'_>, file: &File<'_>) -> Node; | |
| 269 | + | ||
| 270 | + | let base = frame.base(); | |
| 271 | + | ||
| 272 | + | region "git-binary-notice" as Group { | |
| 273 | + | across Wrap { | |
| 274 | + | beside Essential text "Binary file ({file.file_size})."; | |
| 275 | + | beside Essential link "Download" | |
| 276 | + | to get "{base}/raw/{frame.current_ref}/{file.file_path}" navigating; | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /// One line of the file, numbered. | |
| 278 | 282 | /// | |
| 279 | - | /// A table rather than a block [`Node::Code`], because the line numbers are | |
| 280 | - | /// what a reader links to: `#L42` is the address of a line, and a block that | |
| 281 | - | /// owned its own lines would have nothing to hang one on. | |
| 282 | - | fn source(file: &File<'_>) -> Node { | |
| 283 | - | // Two columns, two cells, both written here: the row stays positional | |
| 284 | - | // because the headings it answers to are three lines above it. | |
| 285 | - | Table::new(vec![ | |
| 286 | - | Column::new("Line") | |
| 287 | - | .width(layout::Width::Content) | |
| 288 | - | .priority(layout::Priority::Secondary), | |
| 289 | - | Column::new("Code") | |
| 290 | - | .width(layout::Width::Fill) | |
| 291 | - | .priority(layout::Priority::Essential), | |
| 292 | - | ]) | |
| 293 | - | .rows(file.lines.iter().enumerate().map(|(at, runs)| { | |
| 294 | - | let number = at + 1; | |
| 295 | - | Cells::new([ | |
| 296 | - | // The number is a link to itself, which is how a reader | |
| 297 | - | // gets the address of a line into their clipboard. | |
| 298 | - | Cell::default().part(Node::Link { | |
| 299 | - | text: number.to_string(), | |
| 300 | - | action: Action::get(format!("#L{number}")), | |
| 301 | - | }), | |
| 302 | - | Cell::default().part(Node::Code { | |
| 303 | - | runs: runs.clone(), | |
| 304 | - | language: file.language.map(str::to_owned), | |
| 305 | - | inline: true, | |
| 306 | - | }), | |
| 307 | - | ]) | |
| 308 | - | // `#L42`, which is what the link above points at and what | |
| 309 | - | // `page-git-file.js` scrolls to. `addressed` and not `identified`: | |
| 310 | - | // the first is a document address and the second is the app's own | |
| 311 | - | // name for the row, and this wrote the second for as long as the | |
| 312 | - | // vocabulary had no way to say the first. | |
| 313 | - | .addressed(format!("L{number}")) | |
| 314 | - | })) | |
| 315 | - | .into() | |
| 283 | + | /// A supplier because the number comes off `enumerate` and one more than an | |
| 284 | + | /// index is arithmetic, which the form admits none of. `Line` is not a | |
| 285 | + | /// vocabulary type, so this stays out of the population. | |
| 286 | + | struct Line { | |
| 287 | + | /// Its number, from one. | |
| 288 | + | number: usize, | |
| 289 | + | /// What the lexer made of it. | |
| 290 | + | runs: Vec<Lexeme>, | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// The file's lines, each knowing which one it is. | |
| 294 | + | fn numbered(file: &File<'_>) -> Vec<Line> { | |
| 295 | + | file.lines | |
| 296 | + | .iter() | |
| 297 | + | .enumerate() | |
| 298 | + | .map(|(at, runs)| Line { | |
| 299 | + | number: at + 1, | |
| 300 | + | runs: runs.clone(), | |
| 301 | + | }) | |
| 302 | + | .collect() | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | declare! { | |
| 306 | + | /// The file, one row per line. | |
| 307 | + | /// | |
| 308 | + | /// A table rather than a block `Node::Code`, because the line numbers are | |
| 309 | + | /// what a reader links to: `#L42` is the address of a line, and a block | |
| 310 | + | /// that owned its own lines would have nothing to hang one on. | |
| 311 | + | /// | |
| 312 | + | /// Two columns, two cells, both written here: the row stays positional | |
| 313 | + | /// because the headings it answers to are five lines above it. | |
| 314 | + | shape source(file: &File<'_>) -> Node; | |
| 315 | + | ||
| 316 | + | table { | |
| 317 | + | column "Line" { | |
| 318 | + | width Content; | |
| 319 | + | priority Secondary; | |
| 320 | + | } | |
| 321 | + | column "Code" { | |
| 322 | + | width Fill; | |
| 323 | + | priority Essential; | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | for line in numbered(file) { | |
| 327 | + | cells { | |
| 328 | + | // `#L42`, which is what the link below points at and what | |
| 329 | + | // `page-git-file.js` scrolls to. `addressed` and not | |
| 330 | + | // `identified`: the first is a document address and the second | |
| 331 | + | // is the app's own name for the row, and this wrote the second | |
| 332 | + | // for as long as the vocabulary had no way to say the first. | |
| 333 | + | addressed "L{line.number}"; | |
| 334 | + | // The number is a link to itself, which is how a reader gets | |
| 335 | + | // the address of a line into their clipboard. | |
| 336 | + | cell "" { | |
| 337 | + | link "{line.number}" to get "#L{line.number}"; | |
| 338 | + | } | |
| 339 | + | cell "" { | |
| 340 | + | code line.runs file.language.map(str::to_owned); | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | } | |
| 316 | 345 | } | |
| 317 | 346 | ||
| 318 | 347 | /// The document a browse page is drawn in. |
| @@ -50,8 +50,9 @@ | |||
| 50 | 50 | //! appears inside a conditional inside a table cell. | |
| 51 | 51 | ||
| 52 | 52 | use makeover_layout as layout; | |
| 53 | - | use quasi_router::screen::{Act, Cell, Cells, Column, Field, Table, Tag}; | |
| 54 | - | use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 53 | + | use quasi_declare::declare; | |
| 54 | + | use quasi_router::screen::{Figure, Tag}; | |
| 55 | + | use quasi_router::{Method, Request, Response, RouteError}; | |
| 55 | 56 | use quasi_webview::Webview; | |
| 56 | 57 | ||
| 57 | 58 | use super::Viewer; | |
| @@ -89,194 +90,198 @@ | |||
| 89 | 90 | Webview::new().fragment(&pane(members, owner_split, project, None)) | |
| 90 | 91 | } | |
| 91 | 92 | ||
| 92 | - | /// The panel wrapped in its region, optionally carrying something to say. | |
| 93 | - | fn pane(members: &[ProjectMemberRow], owner_split: i64, project: &str, said: Option<&str>) -> Node { | |
| 94 | - | let mut slot = Slot::new(REGION, RegionKind::Pane); | |
| 95 | - | for node in body(members, owner_split, project, said) { | |
| 96 | - | slot = slot.with(node); | |
| 93 | + | declare! { | |
| 94 | + | /// The panel wrapped in its region, optionally carrying something to say. | |
| 95 | + | shape pane( | |
| 96 | + | members: &[ProjectMemberRow], | |
| 97 | + | owner_split: i64, | |
| 98 | + | project: &str, | |
| 99 | + | said: Option<&str>, | |
| 100 | + | ) -> Node; | |
| 101 | + | ||
| 102 | + | region REGION as Pane { | |
| 103 | + | for node in body(members, owner_split, project, said) { | |
| 104 | + | include node; | |
| 105 | + | } | |
| 97 | 106 | } | |
| 98 | - | Node::Region(slot) | |
| 99 | 107 | } | |
| 100 | 108 | ||
| 101 | - | /// The panel's contents, in order. | |
| 102 | - | fn body( | |
| 103 | - | members: &[ProjectMemberRow], | |
| 104 | - | owner_split: i64, | |
| 105 | - | project: &str, | |
| 106 | - | said: Option<&str>, | |
| 107 | - | ) -> Vec<Node> { | |
| 108 | - | let mut out = vec![ | |
| 109 | - | Node::Link { | |
| 110 | - | text: "Docs: Collaborators".into(), | |
| 111 | - | action: Action::get("/docs/splits").navigating(), | |
| 112 | - | }, | |
| 113 | - | Node::section("Members & Payouts"), | |
| 114 | - | Node::text( | |
| 115 | - | "Add collaborators and set their share of revenue. The project owner receives the \ | |
| 116 | - | remainder.", | |
| 117 | - | ), | |
| 118 | - | Node::stats([ | |
| 119 | - | quasi_router::screen::Figure::new(format!("{owner_split}%"), "Owner's share"), | |
| 120 | - | quasi_router::screen::Figure::new(members.len().to_string(), "Members"), | |
| 121 | - | ]), | |
| 109 | + | /// Whether anybody on the project has yet to accept. | |
| 110 | + | /// | |
| 111 | + | /// A supplier because `any` takes a closure, and it hands back a `bool`, which | |
| 112 | + | /// is the smallest type that works and keeps it out of the population. | |
| 113 | + | fn any_invited(members: &[ProjectMemberRow]) -> bool { | |
| 114 | + | members.iter().any(|member| !member.accepted) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | declare! { | |
| 118 | + | /// The panel's contents, in order. | |
| 119 | + | shape body( | |
| 120 | + | members: &[ProjectMemberRow], | |
| 121 | + | owner_split: i64, | |
| 122 | + | project: &str, | |
| 123 | + | said: Option<&str>, | |
| 124 | + | ) -> Vec<Node>; | |
| 125 | + | ||
| 126 | + | link "Docs: Collaborators" to get "/docs/splits" navigating; | |
| 127 | + | section "Members & Payouts"; | |
| 128 | + | text "Add collaborators and set their share of revenue. The project owner receives the \ | |
| 129 | + | remainder."; | |
| 130 | + | stats [ | |
| 131 | + | Figure::new("{owner_split}%", "Owner's share"), | |
| 132 | + | Figure::new(members.len().to_string(), "Members") | |
| 122 | 133 | ]; | |
| 123 | 134 | ||
| 124 | - | if let Some(said) = said { | |
| 125 | - | out.push(Node::toast(layout::Tone::Success, said)); | |
| 135 | + | // What a write answers with, when it has something to say. An `Option` is | |
| 136 | + | // an iterator of at most one, and `.into_iter()` is the method step that | |
| 137 | + | // says so. | |
| 138 | + | for note in said.into_iter() { | |
| 139 | + | toast layout::Tone::Success note; | |
| 126 | 140 | } | |
| 127 | 141 | ||
| 128 | - | out.push(add_form(project)); | |
| 142 | + | include add_form(project); | |
| 129 | 143 | ||
| 130 | - | if members.is_empty() { | |
| 131 | - | out.push(Node::empty( | |
| 132 | - | "No collaborators yet. Add team members above to share revenue automatically. \ | |
| 133 | - | The project owner receives 100% until splits are configured.", | |
| 134 | - | )); | |
| 135 | - | return out; | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | out.push(table(members, project)); | |
| 144 | + | empty "No collaborators yet. Add team members above to share revenue automatically. \ | |
| 145 | + | The project owner receives 100% until splits are configured." | |
| 146 | + | when members.is_empty(); | |
| 147 | + | include table(members, project) unless members.is_empty(); | |
| 139 | 148 | ||
| 140 | 149 | // The template repeated this inside every unaccepted row's split cell. A | |
| 141 | 150 | // cell is a run of leaves and has no second line, and saying it once under | |
| 142 | 151 | // the table is better anyway: it is one fact about the Invited badge, not a | |
| 143 | 152 | // fact about each person wearing it. | |
| 144 | - | if members.iter().any(|m| !m.accepted) { | |
| 145 | - | out.push(Node::text( | |
| 146 | - | "An invited collaborator's percentage is reserved, but earns nothing until they \ | |
| 147 | - | accept.", | |
| 148 | - | )); | |
| 153 | + | text "An invited collaborator's percentage is reserved, but earns nothing until they \ | |
| 154 | + | accept." | |
| 155 | + | when any_invited(members); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | declare! { | |
| 159 | + | /// The Add Member form, behind the disclosure the template gave it. | |
| 160 | + | /// | |
| 161 | + | /// The disclosure shape, exactly: a region showing at most one frame whose | |
| 162 | + | /// single frame is a labelled sub-region. See wiki | |
| 163 | + | /// `mnw-server-conversion-plan`, "How to say a disclosure". | |
| 164 | + | shape add_form(project: &str) -> Node; | |
| 165 | + | ||
| 166 | + | region "project-members-add" as Group { | |
| 167 | + | region "project-members-add-body" as Pane { | |
| 168 | + | label "Add Member"; | |
| 169 | + | form post "{NEST}/{project}" awaiting { | |
| 170 | + | submit "Add"; | |
| 171 | + | field Text "username" "Username" { | |
| 172 | + | required; | |
| 173 | + | placeholder "Enter username"; | |
| 174 | + | } | |
| 175 | + | // The bounds the API validates against, said once here. | |
| 176 | + | field Number "split_percent" "Split %" { | |
| 177 | + | required; | |
| 178 | + | value "50"; | |
| 179 | + | within "1" "99"; | |
| 180 | + | } | |
| 181 | + | field Text "role" "Role (optional)" { | |
| 182 | + | placeholder "e.g. Producer, Artist, Engineer"; | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | showing_at_most_one None; | |
| 149 | 187 | } | |
| 150 | - | ||
| 151 | - | out | |
| 152 | 188 | } | |
| 153 | 189 | ||
| 154 | - | /// The Add Member form, behind the disclosure the template gave it. | |
| 155 | - | fn add_form(project: &str) -> Node { | |
| 156 | - | // The disclosure shape, exactly: a region showing at most one frame whose | |
| 157 | - | // single frame is a labelled sub-region. See wiki | |
| 158 | - | // `mnw-server-conversion-plan`, "How to say a disclosure". | |
| 159 | - | Node::Region( | |
| 160 | - | Slot::new("project-members-add", RegionKind::Group) | |
| 161 | - | .with(Node::Region( | |
| 162 | - | Slot::new("project-members-add-body", RegionKind::Pane) | |
| 163 | - | .label("Add Member") | |
| 164 | - | .with(Node::Form { | |
| 165 | - | action: Action::post(format!("{NEST}/{project}")).awaiting(), | |
| 166 | - | submit: "Add".into(), | |
| 167 | - | fields: vec![ | |
| 168 | - | hint( | |
| 169 | - | Field::new(layout::FieldKind::Text, "username", "Username") | |
| 170 | - | .required(), | |
| 171 | - | "Enter username", | |
| 172 | - | ), | |
| 173 | - | bounded( | |
| 174 | - | Field::new(layout::FieldKind::Number, "split_percent", "Split %") | |
| 175 | - | .required() | |
| 176 | - | .value("50"), | |
| 177 | - | 1, | |
| 178 | - | 99, | |
| 179 | - | ), | |
| 180 | - | hint( | |
| 181 | - | Field::new(layout::FieldKind::Text, "role", "Role (optional)"), | |
| 182 | - | "e.g. Producer, Artist, Engineer", | |
| 183 | - | ), | |
| 184 | - | ], | |
| 185 | - | }), | |
| 186 | - | )) | |
| 187 | - | .showing_at_most_one(None), | |
| 188 | - | ) | |
| 189 | - | } | |
| 190 | + | declare! { | |
| 191 | + | /// Who is on the project. | |
| 192 | + | /// | |
| 193 | + | /// The columns are declared here and the cells are built in [`row`], a | |
| 194 | + | /// function away. Position is only safe when both lists are in front of you | |
| 195 | + | /// at once, so the cells name their columns and this list is the only place | |
| 196 | + | /// the order is decided. | |
| 197 | + | shape table(members: &[ProjectMemberRow], project: &str) -> Node; | |
| 190 | 198 | ||
| 191 | - | /// Set a field's placeholder. | |
| 192 | - | fn hint(mut field: Field, text: &str) -> Field { | |
| 193 | - | field.placeholder = Some(text.to_owned()); | |
| 194 | - | field | |
| 195 | - | } | |
| 199 | + | table { | |
| 200 | + | column "Member" { | |
| 201 | + | width Fill; | |
| 202 | + | priority Essential; | |
| 203 | + | } | |
| 204 | + | column "Role" { | |
| 205 | + | width Content; | |
| 206 | + | } | |
| 207 | + | column "Split" { | |
| 208 | + | width Content; | |
| 209 | + | } | |
| 210 | + | column "Stripe" { | |
| 211 | + | width Content; | |
| 212 | + | } | |
| 213 | + | column "Added" { | |
| 214 | + | width Content; | |
| 215 | + | priority Optional; | |
| 216 | + | } | |
| 217 | + | // The last column carries no heading, because the button says what it | |
| 218 | + | // does. An empty name is still the name a cell has to match. | |
| 219 | + | column "" { | |
| 220 | + | width Content; | |
| 221 | + | } | |
| 196 | 222 | ||
| 197 | - | /// Bound a numeric field, matching the `min`/`max` the API validates against. | |
| 198 | - | fn bounded(mut field: Field, min: i32, max: i32) -> Field { | |
| 199 | - | field.min = Some(min.to_string()); | |
| 200 | - | field.max = Some(max.to_string()); | |
| 201 | - | field | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | /// Who is on the project. | |
| 205 | - | /// | |
| 206 | - | /// The columns are declared here and the cells are built in [`row`], a function | |
| 207 | - | /// away. Position is only safe when both lists are in front of you at once, so | |
| 208 | - | /// the cells name their columns and this list is the only place the order is | |
| 209 | - | /// decided. | |
| 210 | - | fn table(members: &[ProjectMemberRow], project: &str) -> Node { | |
| 211 | - | Table::new([ | |
| 212 | - | Column::new("Member") | |
| 213 | - | .width(layout::Width::Fill) | |
| 214 | - | .priority(layout::Priority::Essential), | |
| 215 | - | Column::new("Role").width(layout::Width::Content), | |
| 216 | - | Column::new("Split").width(layout::Width::Content), | |
| 217 | - | Column::new("Stripe").width(layout::Width::Content), | |
| 218 | - | Column::new("Added") | |
| 219 | - | .width(layout::Width::Content) | |
| 220 | - | .priority(layout::Priority::Optional), | |
| 221 | - | Column::new("").width(layout::Width::Content), | |
| 222 | - | ]) | |
| 223 | - | .rows(members.iter().map(|m| row(m, project))) | |
| 224 | - | .into() | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | /// One collaborator. | |
| 228 | - | fn row(member: &ProjectMemberRow, project: &str) -> Cells { | |
| 229 | - | let shown = member.display_name.as_deref().unwrap_or(&member.username); | |
| 230 | - | ||
| 231 | - | // The template packed the percentage, the badge and the sentence into one | |
| 232 | - | // `<td>`. Three facts, said as three. | |
| 233 | - | let mut split = Cell::new(format!("{}%", member.split_percent)); | |
| 234 | - | if !member.accepted { | |
| 235 | - | let mut invited = Tag::badge("Invited"); | |
| 236 | - | invited.tone = layout::Tone::Warning; | |
| 237 | - | split = split.token(invited); | |
| 223 | + | for member in members.iter() { | |
| 224 | + | include row(member, project); | |
| 225 | + | } | |
| 238 | 226 | } | |
| 227 | + | } | |
| 239 | 228 | ||
| 240 | - | let mut stripe = Tag::badge(if member.stripe_connected { | |
| 229 | + | /// What to call a collaborator: their display name, or their handle. | |
| 230 | + | fn shown(member: &ProjectMemberRow) -> &str { | |
| 231 | + | member.display_name.as_deref().unwrap_or(&member.username) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// What the Stripe badge reads. | |
| 235 | + | fn stripe_label(member: &ProjectMemberRow) -> &'static str { | |
| 236 | + | if member.stripe_connected { | |
| 241 | 237 | "Connected" | |
| 242 | 238 | } else { | |
| 243 | 239 | "Not connected" | |
| 244 | - | }); | |
| 245 | - | stripe.tone = if member.stripe_connected { | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// How it is toned. A collaborator who cannot be paid is a warning. | |
| 244 | + | fn stripe_tone(member: &ProjectMemberRow) -> layout::Tone { | |
| 245 | + | if member.stripe_connected { | |
| 246 | 246 | layout::Tone::Success | |
| 247 | 247 | } else { | |
| 248 | 248 | layout::Tone::Warning | |
| 249 | - | }; | |
| 249 | + | } | |
| 250 | + | } | |
| 250 | 251 | ||
| 251 | - | // Each cell names the column it belongs to. The headings live in [`table`], | |
| 252 | - | // so counting to a position here would be counting against a list this | |
| 253 | - | // function cannot see. | |
| 254 | - | Cells::default() | |
| 252 | + | declare! { | |
| 253 | + | /// One collaborator. | |
| 254 | + | /// | |
| 255 | + | /// Each cell names the column it belongs to. The headings live in | |
| 256 | + | /// [`table`], so counting to a position here would be counting against a | |
| 257 | + | /// list this function cannot see. | |
| 258 | + | shape row(member: &ProjectMemberRow, project: &str) -> Cells; | |
| 259 | + | ||
| 260 | + | cells { | |
| 255 | 261 | // The template drew the display name and `@username` as two lines in | |
| 256 | 262 | // one `<td>`. A cell is a run of leaves and has no second line, so the | |
| 257 | 263 | // handle rides in the value where a reader still sees it. | |
| 258 | - | .at( | |
| 259 | - | "Member", | |
| 260 | - | Cell::new(format!("{shown} (@{})", member.username)) | |
| 261 | - | .activate(Action::get(format!("/u/{}", member.username)).navigating()), | |
| 262 | - | ) | |
| 263 | - | .at("Role", Cell::new(member.role.clone())) | |
| 264 | - | .at("Split", split) | |
| 265 | - | .at("Stripe", Cell::new(String::new()).token(stripe)) | |
| 266 | - | .at("Added", Cell::new(member.added_at.clone())) | |
| 267 | - | // The last column carries no heading, because the button says what it | |
| 268 | - | // does. An empty name is still the name the cell has to match. | |
| 269 | - | .at( | |
| 270 | - | "", | |
| 271 | - | Cell::new(String::new()).act( | |
| 272 | - | Act::new( | |
| 273 | - | "Remove", | |
| 274 | - | Action::delete(format!("{NEST}/{project}/{}", member.user_id)), | |
| 275 | - | ) | |
| 276 | - | .tone(layout::Tone::Danger) | |
| 277 | - | .confirm(format!("Remove {shown} from this project?")), | |
| 278 | - | ), | |
| 279 | - | ) | |
| 264 | + | cell at "Member" "{shown(member)} (@{member.username})" { | |
| 265 | + | activate to get "/u/{member.username}" navigating; | |
| 266 | + | } | |
| 267 | + | cell at "Role" member.role.clone(); | |
| 268 | + | // The template packed the percentage, the badge and the sentence into | |
| 269 | + | // one `<td>`. Three facts, said as three: the sentence moved under the | |
| 270 | + | // table, and the badge is the cell's token. | |
| 271 | + | cell at "Split" "{member.split_percent}%" { | |
| 272 | + | token Tag::badge("Invited").tone(layout::Tone::Warning) unless member.accepted; | |
| 273 | + | } | |
| 274 | + | cell at "Stripe" "" { | |
| 275 | + | token Tag::badge(stripe_label(member)).tone(stripe_tone(member)); | |
| 276 | + | } | |
| 277 | + | cell at "Added" member.added_at.clone(); | |
| 278 | + | cell at "" "" { | |
| 279 | + | act "Remove" to delete "{NEST}/{project}/{member.user_id}" { | |
| 280 | + | tone Danger; | |
| 281 | + | confirm "Remove {shown(member)} from this project?"; | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | } | |
| 280 | 285 | } | |
| 281 | 286 | ||
| 282 | 287 | /// The project this write is about, and the reader's right to touch it. |
| @@ -40,9 +40,9 @@ | |||
| 40 | 40 | //! `Email` was, and the format named once as `layout::DATE_FORMAT`. See | |
| 41 | 41 | //! [`add_token_form`]. | |
| 42 | 42 | ||
| 43 | - | use makeover_layout as layout; | |
| 44 | - | use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Field, Table}; | |
| 45 | - | use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 43 | + | use quasi_declare::declare; | |
| 44 | + | use quasi_router::screen::Choice; | |
| 45 | + | use quasi_router::{Method, Request, Response, RouteError}; | |
| 46 | 46 | use quasi_webview::Webview; | |
| 47 | 47 | ||
| 48 | 48 | use super::Viewer; | |
| @@ -196,215 +196,245 @@ | |||
| 196 | 196 | at.map_or_else(|| "Never".to_owned(), |d| d.format("%b %d, %Y").to_string()) | |
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | - | /// Everything inside the settings pane. | |
| 200 | - | /// | |
| 201 | - | /// Split from the handler so a test can build it without a database, which is | |
| 202 | - | /// the same split `quasi_spike` used and the reason the description layer is | |
| 203 | - | /// testable at all: the screen is a value. | |
| 204 | - | fn pane(username: &str, keys: &[KeyView], tokens: &[TokenView], themes: &[ThemeOption]) -> Node { | |
| 205 | - | Node::Region( | |
| 206 | - | Slot::new(REGION, RegionKind::Pane) | |
| 207 | - | .with(Node::section("SSH Keys")) | |
| 208 | - | .with(Node::text(format!( | |
| 209 | - | "Manage SSH keys for git clone and push access. \ | |
| 210 | - | Clone URL: git@makenot.work:{username}/{{repo}}.git" | |
| 211 | - | ))) | |
| 212 | - | .with(keys_list(keys)) | |
| 213 | - | .with(add_key_form()) | |
| 214 | - | .with(Node::section("Console theme")) | |
| 215 | - | .with(Node::text( | |
| 216 | - | "Color palette for your terminal dashboard over ssh makenot.work.", | |
| 217 | - | )) | |
| 218 | - | .with(theme_form(themes)) | |
| 219 | - | .with(Node::section("Access Tokens (HTTPS)")) | |
| 220 | - | .with(Node::text(format!( | |
| 221 | - | "Personal access tokens for git over HTTPS. Use a token as the password. \ | |
| 222 | - | Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git" | |
| 223 | - | ))) | |
| 224 | - | .with(tokens_list(tokens)) | |
| 225 | - | .with(add_token_form()), | |
| 226 | - | ) | |
| 227 | - | } | |
| 199 | + | declare! { | |
| 200 | + | /// Everything inside the settings pane. | |
| 201 | + | /// | |
| 202 | + | /// Split from the handler so a test can build it without a database, which | |
| 203 | + | /// is the same split `quasi_spike` used and the reason the description | |
| 204 | + | /// layer is testable at all: the screen is a value. | |
| 205 | + | shape pane( | |
| 206 | + | username: &str, | |
| 207 | + | keys: &[KeyView], | |
| 208 | + | tokens: &[TokenView], | |
| 209 | + | themes: &[ThemeOption], | |
| 210 | + | ) -> Node; | |
| 228 | 211 | ||
| 229 | - | /// The registered keys, or the sentence saying there are none. | |
| 230 | - | fn keys_list(keys: &[KeyView]) -> Node { | |
| 231 | - | if keys.is_empty() { | |
| 232 | - | return Node::empty("No SSH keys registered."); | |
| 233 | - | } | |
| 234 | - | // The template's four columns, in its order, the last one the empty | |
| 235 | - | // header its actions sit under. | |
| 236 | - | let columns = vec![ | |
| 237 | - | Column::new("Fingerprint") | |
| 238 | - | .width(layout::Width::Fill) | |
| 239 | - | .priority(layout::Priority::Essential), | |
| 240 | - | Column::new("Label").width(layout::Width::Content), | |
| 241 | - | Column::new("Added") | |
| 242 | - | .width(layout::Width::Content) | |
| 243 | - | .priority(layout::Priority::Optional), | |
| 244 | - | Column::new("") | |
| 245 | - | .width(layout::Width::Content) | |
| 246 | - | .priority(layout::Priority::Essential), | |
| 247 | - | ]; | |
| 248 | - | // The cells stay positional because the column list is right here to read | |
| 249 | - | // against them: four columns, four cells in every row, and no branch that | |
| 250 | - | // drops one. Naming would buy nothing a reader cannot already see. | |
| 251 | - | // | |
| 252 | - | // No paging described here either: every one of these tables is a whole | |
| 253 | - | // set the handler already counted. | |
| 254 | - | Table::new(columns) | |
| 255 | - | .rows(keys.iter().map(|key| { | |
| 256 | - | Cells::new([ | |
| 257 | - | // `19d7602d`. A SHA256 fingerprint is a run of characters a | |
| 258 | - | // reader compares against another one, and a proportional | |
| 259 | - | // face makes that harder than it has to be. Plain rather | |
| 260 | - | // than classified: nothing lexed it and nothing should, so | |
| 261 | - | // what this buys is the monospace and not a colour. | |
| 262 | - | Cell::default().part(Node::Code { | |
| 263 | - | runs: vec![quasi_router::screen::Lexeme::plain(key.fingerprint.clone())], | |
| 264 | - | language: None, | |
| 265 | - | inline: true, | |
| 266 | - | }), | |
| 267 | - | Cell::new(key.label.clone()), | |
| 268 | - | Cell::new(format!("Added {}", key.added)), | |
| 269 | - | Cell::acts([Act::new( | |
| 270 | - | "Remove", | |
| 271 | - | // This screen's own route, under its own nest, so the | |
| 272 | - | // answer is the pane it changed. It addressed the API | |
| 273 | - | // route until 2026-08-11 and swapped a whole Askama | |
| 274 | - | // table into this button; see `remove_key`. | |
| 275 | - | // `awaiting` for the same reason the forms carry it: the | |
| 276 | - | // answer is the whole pane rebuilt, so there is a wait | |
| 277 | - | // with nothing on screen saying so. The confirm gates | |
| 278 | - | // the first press, not the second one after it. | |
| 279 | - | Action::delete(format!("{PATH}/keys/{}", key.id)).awaiting(), | |
| 280 | - | ) | |
| 281 | - | // The template asked with hx-confirm. Said here, a terminal | |
| 282 | - | // host asks in its own way and no host can forget to ask. | |
| 283 | - | .confirm("Remove this SSH key?") | |
| 284 | - | .tone(layout::Tone::Danger)]), | |
| 285 | - | ]) | |
| 286 | - | })) | |
| 287 | - | .into() | |
| 288 | - | } | |
| 212 | + | region REGION as Pane { | |
| 213 | + | section "SSH Keys"; | |
| 214 | + | text "Manage SSH keys for git clone and push access. \ | |
| 215 | + | Clone URL: git@makenot.work:{username}/{{repo}}.git"; | |
| 216 | + | include keys_list(keys); | |
| 217 | + | include add_key_form(); | |
| 289 | 218 | ||
| 290 | - | /// The add-a-key form. | |
| 291 | - | /// | |
| 292 | - | /// `awaiting` is the double-submit guard: this creates a record, so a second | |
| 293 | - | /// submit while the first is in flight is a duplicate key. It reads as one word | |
| 294 | - | /// here and the renderer locks the submit button from it, which is what | |
| 295 | - | /// `frontend/src/core/loading.ts` was written to do by hand and is losing | |
| 296 | - | /// ground against. | |
| 297 | - | fn add_key_form() -> Node { | |
| 298 | - | Node::Form { | |
| 299 | - | action: Action::post("/api/users/me/ssh-keys").awaiting(), | |
| 300 | - | submit: "Add SSH Key".into(), | |
| 301 | - | fields: vec![ | |
| 302 | - | Field::new(layout::FieldKind::Textarea, "public_key", "Public Key") | |
| 303 | - | .required() | |
| 304 | - | .hint( | |
| 305 | - | "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file", | |
| 306 | - | ), | |
| 307 | - | Field::new(layout::FieldKind::Text, "label", "Label"), | |
| 308 | - | ], | |
| 219 | + | section "Console theme"; | |
| 220 | + | text "Color palette for your terminal dashboard over ssh makenot.work."; | |
| 221 | + | include theme_form(themes); | |
| 222 | + | ||
| 223 | + | section "Access Tokens (HTTPS)"; | |
| 224 | + | text "Personal access tokens for git over HTTPS. Use a token as the password. \ | |
| 225 | + | Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git"; | |
| 226 | + | include tokens_list(tokens); | |
| 227 | + | include add_token_form(); | |
| 309 | 228 | } | |
| 310 | 229 | } | |
| 311 | 230 | ||
| 312 | - | /// The console-theme picker. | |
| 313 | - | fn theme_form(themes: &[ThemeOption]) -> Node { | |
| 314 | - | let options: Vec<Choice> = themes | |
| 231 | + | declare! { | |
| 232 | + | /// The registered keys, or the sentence saying there are none. | |
| 233 | + | /// | |
| 234 | + | /// The template's four columns, in its order, the last one the empty header | |
| 235 | + | /// its actions sit under. | |
| 236 | + | /// | |
| 237 | + | /// The cells stay positional because the column list is right here to read | |
| 238 | + | /// against them: four columns, four cells in every row, and no branch that | |
| 239 | + | /// drops one. Naming would buy nothing a reader cannot already see. | |
| 240 | + | /// | |
| 241 | + | /// No paging described here either: every one of these tables is a whole | |
| 242 | + | /// set the handler already counted. | |
| 243 | + | shape keys_list(keys: &[KeyView]) -> Node; | |
| 244 | + | ||
| 245 | + | given keys.is_empty() { | |
| 246 | + | true -> empty "No SSH keys registered."; | |
| 247 | + | otherwise -> table { | |
| 248 | + | column "Fingerprint" { | |
| 249 | + | width Fill; | |
| 250 | + | priority Essential; | |
| 251 | + | } | |
| 252 | + | column "Label" { | |
| 253 | + | width Content; | |
| 254 | + | } | |
| 255 | + | column "Added" { | |
| 256 | + | width Content; | |
| 257 | + | priority Optional; | |
| 258 | + | } | |
| 259 | + | column "" { | |
| 260 | + | width Content; | |
| 261 | + | priority Essential; | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | for key in keys.iter() { | |
| 265 | + | cells { | |
| 266 | + | // `19d7602d`. A SHA256 fingerprint is a run of characters a | |
| 267 | + | // reader compares against another one, and a proportional | |
| 268 | + | // face makes that harder than it has to be. Plain rather | |
| 269 | + | // than classified: nothing lexed it and nothing should, so | |
| 270 | + | // what this buys is the monospace and not a colour. | |
| 271 | + | cell "" { | |
| 272 | + | literal key.fingerprint.clone(); | |
| 273 | + | } | |
| 274 | + | cell key.label.clone(); | |
| 275 | + | cell "Added {key.added}"; | |
| 276 | + | cell "" { | |
| 277 | + | // This screen's own route, under its own nest, so the | |
| 278 | + | // answer is the pane it changed. It addressed the API | |
| 279 | + | // route until 2026-08-11 and swapped a whole Askama | |
| 280 | + | // table into this button; see `remove_key`. `awaiting` | |
| 281 | + | // for the same reason the forms carry it: the answer is | |
| 282 | + | // the whole pane rebuilt, so there is a wait with | |
| 283 | + | // nothing on screen saying so. The confirm gates the | |
| 284 | + | // first press, not the second one after it. | |
| 285 | + | act "Remove" to delete "{PATH}/keys/{key.id}" awaiting { | |
| 286 | + | // The template asked with hx-confirm. Said here, a | |
| 287 | + | // terminal host asks in its own way and no host can | |
| 288 | + | // forget to ask. | |
| 289 | + | confirm "Remove this SSH key?"; | |
| 290 | + | tone Danger; | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | declare! { | |
| 300 | + | /// The add-a-key form. | |
| 301 | + | /// | |
| 302 | + | /// `awaiting` is the double-submit guard: this creates a record, so a | |
| 303 | + | /// second submit while the first is in flight is a duplicate key. It reads | |
| 304 | + | /// as one word here and the renderer locks the submit button from it, which | |
| 305 | + | /// is what `frontend/src/core/loading.ts` was written to do by hand and is | |
| 306 | + | /// losing ground against. | |
| 307 | + | shape add_key_form() -> Node; | |
| 308 | + | ||
| 309 | + | form post "/api/users/me/ssh-keys" awaiting { | |
| 310 | + | submit "Add SSH Key"; | |
| 311 | + | field Textarea "public_key" "Public Key" { | |
| 312 | + | required; | |
| 313 | + | hint "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file"; | |
| 314 | + | } | |
| 315 | + | field Text "label" "Label"; | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | /// Which theme the reader is on, or nothing. | |
| 320 | + | /// | |
| 321 | + | /// A supplier because `find` takes a closure. It hands back a `String`, which | |
| 322 | + | /// is the smallest type that works and keeps it out of the population, and | |
| 323 | + | /// [`a_theme_is_chosen`] is the predicate that decides whether it is asked for. | |
| 324 | + | fn chosen_theme(themes: &[ThemeOption]) -> String { | |
| 325 | + | themes | |
| 315 | 326 | .iter() | |
| 316 | - | .map(|t| Choice::new(t.id.clone(), t.name.clone())) | |
| 317 | - | .collect(); | |
| 318 | - | let mut field = Field::select("theme_id", "Console theme", options).hint( | |
| 319 | - | "Following the terminal picks a light or dark palette from what your terminal reports. \ | |
| 320 | - | Separate from your profile theme, which is what visitors see.", | |
| 321 | - | ); | |
| 322 | - | if let Some(chosen) = themes.iter().find(|t| t.selected) { | |
| 323 | - | field = field.value(chosen.id.clone()); | |
| 324 | - | } | |
| 325 | - | Node::Form { | |
| 326 | - | // A PUT, so a second submit overwrites rather than duplicating. Marked | |
| 327 | - | // anyway: the wait is real and the reader has no other way to tell the | |
| 328 | - | // save landed from the save being slow. | |
| 329 | - | action: Action::put("/api/users/me/console-theme").awaiting(), | |
| 330 | - | submit: "Save Theme".into(), | |
| 331 | - | fields: vec![field], | |
| 327 | + | .find(|theme| theme.selected) | |
| 328 | + | .map(|theme| theme.id.clone()) | |
| 329 | + | .unwrap_or_default() | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// Whether any of them is marked. | |
| 333 | + | fn a_theme_is_chosen(themes: &[ThemeOption]) -> bool { | |
| 334 | + | themes.iter().any(|theme| theme.selected) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | declare! { | |
| 338 | + | /// The console-theme picker. | |
| 339 | + | shape theme_form(themes: &[ThemeOption]) -> Node; | |
| 340 | + | ||
| 341 | + | // A PUT, so a second submit overwrites rather than duplicating. Marked | |
| 342 | + | // anyway: the wait is real and the reader has no other way to tell the save | |
| 343 | + | // landed from the save being slow. | |
| 344 | + | form put "/api/users/me/console-theme" awaiting { | |
| 345 | + | submit "Save Theme"; | |
| 346 | + | field Select "theme_id" "Console theme" { | |
| 347 | + | hint "Following the terminal picks a light or dark palette from what your \ | |
| 348 | + | terminal reports. Separate from your profile theme, which is what \ | |
| 349 | + | visitors see."; | |
| 350 | + | for theme in themes.iter() { | |
| 351 | + | option Choice::new(theme.id.clone(), theme.name.clone()); | |
| 352 | + | } | |
| 353 | + | value chosen_theme(themes) when a_theme_is_chosen(themes); | |
| 354 | + | } | |
| 332 | 355 | } | |
| 333 | 356 | } | |
| 334 | 357 | ||
| 335 | - | /// The issued tokens, or the sentence saying there are none. | |
| 336 | - | fn tokens_list(tokens: &[TokenView]) -> Node { | |
| 337 | - | if tokens.is_empty() { | |
| 338 | - | return Node::empty("No access tokens."); | |
| 358 | + | declare! { | |
| 359 | + | /// The issued tokens, or the sentence saying there are none. | |
| 360 | + | /// | |
| 361 | + | /// The four independent facts the list version had to run together into one | |
| 362 | + | /// `meta` string, back in their own columns. This is the table that lost the | |
| 363 | + | /// most by being a list: name, scope, expiry and last use are read down the | |
| 364 | + | /// column, which is what a table is for. | |
| 365 | + | /// | |
| 366 | + | /// Positional for the same reason the key table is: the columns are in this | |
| 367 | + | /// declaration, every row carries all five cells, and nothing branches. | |
| 368 | + | /// | |
| 369 | + | /// No paging described here either: every one of these tables is a whole | |
| 370 | + | /// set the handler already counted. | |
| 371 | + | shape tokens_list(tokens: &[TokenView]) -> Node; | |
| 372 | + | ||
| 373 | + | given tokens.is_empty() { | |
| 374 | + | true -> empty "No access tokens."; | |
| 375 | + | otherwise -> table { | |
| 376 | + | column "Name" { | |
| 377 | + | width Fill; | |
| 378 | + | priority Essential; | |
| 379 | + | } | |
| 380 | + | column "Scope" { | |
| 381 | + | width Content; | |
| 382 | + | } | |
| 383 | + | column "Expires" { | |
| 384 | + | width Content; | |
| 385 | + | priority Optional; | |
| 386 | + | } | |
| 387 | + | column "Last used" { | |
| 388 | + | width Content; | |
| 389 | + | priority Optional; | |
| 390 | + | } | |
| 391 | + | column "" { | |
| 392 | + | width Content; | |
| 393 | + | priority Essential; | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | for token in tokens.iter() { | |
| 397 | + | cells { | |
| 398 | + | cell token.name.clone(); | |
| 399 | + | cell token.scope; | |
| 400 | + | cell token.expires.clone(); | |
| 401 | + | cell token.last_used.clone(); | |
| 402 | + | cell "" { | |
| 403 | + | act "Revoke" to delete "{PATH}/tokens/{token.id}" awaiting { | |
| 404 | + | confirm "Revoke this token?"; | |
| 405 | + | tone Danger; | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | } | |
| 339 | 411 | } | |
| 340 | - | // The four independent facts the list version had to run together into | |
| 341 | - | // one `meta` string, back in their own columns. This is the table that | |
| 342 | - | // lost the most by being a list: name, scope, expiry and last use are | |
| 343 | - | // read down the column, which is what a table is for. | |
| 344 | - | let columns = vec![ | |
| 345 | - | Column::new("Name") | |
| 346 | - | .width(layout::Width::Fill) | |
| 347 | - | .priority(layout::Priority::Essential), | |
| 348 | - | Column::new("Scope").width(layout::Width::Content), | |
| 349 | - | Column::new("Expires") | |
| 350 | - | .width(layout::Width::Content) | |
| 351 | - | .priority(layout::Priority::Optional), | |
| 352 | - | Column::new("Last used") | |
| 353 | - | .width(layout::Width::Content) | |
| 354 | - | .priority(layout::Priority::Optional), | |
| 355 | - | Column::new("") | |
| 356 | - | .width(layout::Width::Content) | |
| 357 | - | .priority(layout::Priority::Essential), | |
| 358 | - | ]; | |
| 359 | - | // Positional for the same reason the key table is: the columns are in | |
| 360 | - | // this function, every row carries all five cells, and nothing branches. | |
| 361 | - | // | |
| 362 | - | // No paging described here either: every one of these tables is a whole | |
| 363 | - | // set the handler already counted. | |
| 364 | - | Table::new(columns) | |
| 365 | - | .rows(tokens.iter().map(|token| { | |
| 366 | - | Cells::new([ | |
| 367 | - | Cell::new(token.name.clone()), | |
| 368 | - | Cell::new(token.scope), | |
| 369 | - | Cell::new(token.expires.clone()), | |
| 370 | - | Cell::new(token.last_used.clone()), | |
| 371 | - | Cell::acts([Act::new( | |
| 372 | - | "Revoke", | |
| 373 | - | Action::delete(format!("{PATH}/tokens/{}", token.id)).awaiting(), | |
| 374 | - | ) | |
| 375 | - | .confirm("Revoke this token?") | |
| 376 | - | .tone(layout::Tone::Danger)]), | |
| 377 | - | ]) | |
| 378 | - | })) | |
| 379 | - | .into() | |
| 380 | 412 | } | |
| 381 | 413 | ||
| 382 | - | /// The mint-a-token form. | |
| 383 | - | /// | |
| 384 | - | /// `expires_on` was a `Text` field carrying a "YYYY-MM-DD" hint, against an | |
| 385 | - | /// Askama form that spelled it `<input type="date">`: no native picker, no | |
| 386 | - | /// platform validation, and the hint doing both jobs in prose. That was a | |
| 387 | - | /// vocabulary gap rather than a choice, and `layout::FieldKind::Date` closed it | |
| 388 | - | /// at makeover-layout 0.15.0. It did become one word, and the hint came out with | |
| 389 | - | /// it: the format is the description's now, `layout::DATE_FORMAT`, so saying it | |
| 390 | - | /// again here would be a second place for it to drift. | |
| 391 | - | fn add_token_form() -> Node { | |
| 392 | - | Node::Form { | |
| 393 | - | // Creates a record, and unlike an SSH key the answer is a secret shown | |
| 394 | - | // once. Two of these from one impatient double-press is two tokens, one | |
| 395 | - | // of which the creator never sees and cannot recognise later. | |
| 396 | - | action: Action::post("/api/users/me/git-tokens").awaiting(), | |
| 397 | - | submit: "Create Token".into(), | |
| 398 | - | fields: vec![ | |
| 399 | - | Field::new(layout::FieldKind::Text, "name", "Name").required(), | |
| 400 | - | Field::new(layout::FieldKind::Date, "expires_on", "Expires (optional)") | |
| 401 | - | .hint("Leave blank for a token that does not expire."), | |
| 402 | - | Field::new( | |
| 403 | - | layout::FieldKind::Checkbox, | |
| 404 | - | "can_push", | |
| 405 | - | "Allow push (write access)", | |
| 406 | - | ), | |
| 407 | - | ], | |
| 414 | + | declare! { | |
| 415 | + | /// The mint-a-token form. | |
| 416 | + | /// | |
| 417 | + | /// `expires_on` was a `Text` field carrying a "YYYY-MM-DD" hint, against an | |
| 418 | + | /// Askama form that spelled it `<input type="date">`: no native picker, no | |
| 419 | + | /// platform validation, and the hint doing both jobs in prose. That was a | |
| 420 | + | /// vocabulary gap rather than a choice, and `layout::FieldKind::Date` closed | |
| 421 | + | /// it at makeover-layout 0.15.0. It did become one word, and the hint came | |
| 422 | + | /// out with it: the format is the description's now, `layout::DATE_FORMAT`, | |
| 423 | + | /// so saying it again here would be a second place for it to drift. | |
| 424 | + | shape add_token_form() -> Node; | |
| 425 | + | ||
| 426 | + | // Creates a record, and unlike an SSH key the answer is a secret shown | |
| 427 | + | // once. Two of these from one impatient double-press is two tokens, one of | |
| 428 | + | // which the creator never sees and cannot recognise later. | |
| 429 | + | form post "/api/users/me/git-tokens" awaiting { | |
| 430 | + | submit "Create Token"; | |
| 431 | + | field Text "name" "Name" { | |
| 432 | + | required; | |
| 433 | + | } | |
| 434 | + | field Date "expires_on" "Expires (optional)" { | |
| 435 | + | hint "Leave blank for a token that does not expire."; | |
| 436 | + | } | |
| 437 | + | field Checkbox "can_push" "Allow push (write access)"; | |
| 408 | 438 | } | |
| 409 | 439 | } | |
| 410 | 440 | ||
| @@ -424,6 +454,7 @@ | |||
| 424 | 454 | mod tests { | |
| 425 | 455 | use super::*; | |
| 426 | 456 | use quasi_axum::Serves; | |
| 457 | + | use quasi_router::Node; | |
| 427 | 458 | ||
| 428 | 459 | fn key(id: &str, fingerprint: &str) -> KeyView { | |
| 429 | 460 | KeyView { |
| @@ -35,10 +35,8 @@ | |||
| 35 | 35 | //! from fields the screen holds. | |
| 36 | 36 | ||
| 37 | 37 | use makeover_layout as layout; | |
| 38 | - | use quasi_router::screen::Row; | |
| 39 | - | use quasi_router::{ | |
| 40 | - | Act, Action, Document, Feed, FeedKind, Node, RegionKind, Screen as Described, Slot, | |
| 41 | - | }; | |
| 38 | + | use quasi_declare::declare; | |
| 39 | + | use quasi_router::{Document, Feed, FeedKind}; | |
| 42 | 40 | use quasi_webview::Webview; | |
| 43 | 41 | ||
| 44 | 42 | use crate::types::{Collection, CustomLink, Project, User}; | |
| @@ -106,6 +104,43 @@ | |||
| 106 | 104 | .map_or_else(|| "Creator on Makenotwork".to_owned(), str::to_owned) | |
| 107 | 105 | } | |
| 108 | 106 | ||
| 107 | + | /// Whether the creator has written anything about themselves. | |
| 108 | + | /// | |
| 109 | + | /// A predicate rather than an `Option` the description reaches into, which | |
| 110 | + | /// is the call `super::embeds::ItemView::has_cover` records: the form has | |
| 111 | + | /// no binding pattern, and whitespace is not a bio. | |
| 112 | + | fn has_bio(&self) -> bool { | |
| 113 | + | !self.bio().trim().is_empty() | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// What they wrote, or nothing. R9: the paragraph is built whether or not | |
| 117 | + | /// [`Self::has_bio`] places it. | |
| 118 | + | fn bio(&self) -> &str { | |
| 119 | + | self.user.bio.as_deref().unwrap_or_default() | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// Whether this reader is offered the follow control. | |
| 123 | + | /// | |
| 124 | + | /// Following is a write, so it needs a session, and a creator does not | |
| 125 | + | /// follow themselves. Both were the template's conditions, and the count | |
| 126 | + | /// stands in where the control does not. | |
| 127 | + | fn can_follow(&self) -> bool { | |
| 128 | + | self.signed_in && !self.is_own_profile | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// What a tip on this page is about. | |
| 132 | + | /// | |
| 133 | + | /// A method rather than the literal the caller used to write inline: a | |
| 134 | + | /// `Type { .. }` aggregate is the form's hard limit, and the offer is a | |
| 135 | + | /// fact about the profile anyway. | |
| 136 | + | fn tip_offer(&self) -> super::tip::Offer<'_> { | |
| 137 | + | super::tip::Offer { | |
| 138 | + | creator_id: self.user_id, | |
| 139 | + | project_id: None, | |
| 140 | + | signed_in: self.signed_in, | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 109 | 144 | /// The picture a link preview shows: their avatar, or the site's card. | |
| 110 | 145 | fn image(&self) -> String { | |
| 111 | 146 | self.user | |
| @@ -136,134 +171,110 @@ | |||
| 136 | 171 | } | |
| 137 | 172 | } | |
| 138 | 173 | ||
| 139 | - | /// The whole document. | |
| 140 | - | #[must_use] | |
| 141 | - | pub fn screen(profile: &Profile<'_>, theme_css: &str) -> Described { | |
| 142 | - | let user = profile.user; | |
| 143 | - | let name = user.display_name_or_username(); | |
| 144 | - | let feed = feed_path(&user.username); | |
| 174 | + | declare! { | |
| 175 | + | /// The whole document. | |
| 176 | + | /// | |
| 177 | + | /// The creator's Tier 0 sheet rides on `styled`, written last in the head | |
| 178 | + | /// and outside the layer statement, which is the property the hand-written | |
| 179 | + | /// `<style id="creator-theme">` block existed to hold. | |
| 180 | + | #[must_use] | |
| 181 | + | pub shape screen(profile: &Profile<'_>, theme_css: &str) -> Screen; | |
| 145 | 182 | ||
| 146 | - | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane); | |
| 183 | + | let name = profile.user.display_name_or_username(); | |
| 184 | + | let feed = feed_path(&profile.user.username); | |
| 147 | 185 | ||
| 148 | - | // A creator on a break, said before anything they published: a reader | |
| 149 | - | // about to buy is the one who needs it. | |
| 150 | - | if profile.paused { | |
| 151 | - | page = page.with(Node::banner( | |
| 152 | - | layout::Tone::Info, | |
| 153 | - | "This creator is currently on break. Existing purchases remain accessible.", | |
| 154 | - | )); | |
| 186 | + | screen single "{name} - Makenotwork" { | |
| 187 | + | measured MEASURE; | |
| 188 | + | documented Document::default() | |
| 189 | + | .classed(crate::shell::body_class(MEASURE, &["user-page"])) | |
| 190 | + | .styled(theme_css.to_owned()); | |
| 191 | + | summarised profile.summary(); | |
| 192 | + | illustrated profile.image(); | |
| 193 | + | canonical_at profile.canonical(); | |
| 194 | + | about quasi_router::SocialKind::Profile; | |
| 195 | + | syndicating Feed::new(FeedKind::Rss, "{name} - RSS Feed", feed); | |
| 196 | + | ||
| 197 | + | region PAGE_REGION as Pane { | |
| 198 | + | // A creator on a break, said before anything they published: a | |
| 199 | + | // reader about to buy is the one who needs it. | |
| 200 | + | banner layout::Tone::Info | |
| 201 | + | "This creator is currently on break. Existing purchases remain accessible." | |
| 202 | + | when profile.paused; | |
| 203 | + | ||
| 204 | + | page name; | |
| 205 | + | text profile.user.username.clone(); | |
| 206 | + | text profile.bio() when profile.has_bio(); | |
| 207 | + | ||
| 208 | + | act "Edit Profile" to get "/dashboard?tab=settings" navigating | |
| 209 | + | when profile.is_own_profile; | |
| 210 | + | ||
| 211 | + | include super::follow::control( | |
| 212 | + | "user", | |
| 213 | + | profile.user_id, | |
| 214 | + | profile.is_following, | |
| 215 | + | profile.follower_count | |
| 216 | + | ) when profile.can_follow(); | |
| 217 | + | // An `-> Option<_>` shape is placed with a loop, because an | |
| 218 | + | // `Option` is an iterator of at most one. `.into_iter()` is a | |
| 219 | + | // method step the hole grammar already admits, and it is what says | |
| 220 | + | // at-most-one was meant rather than a fallible iterated by | |
| 221 | + | // accident. The guard is the other half of the control's: a reader | |
| 222 | + | // offered the control is not also told the count. | |
| 223 | + | for counted in super::follow::count_only(profile.follower_count).into_iter() { | |
| 224 | + | include counted unless profile.can_follow(); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | include super::tip::control(&profile.tip_offer()) when profile.tips_enabled; | |
| 228 | + | ||
| 229 | + | act "RSS Feed" to get feed.clone() navigating; | |
| 230 | + | // Already sayable: `copying` is "the value this press puts on the | |
| 231 | + | // clipboard", which is the whole of what `data-copy-link` meant. | |
| 232 | + | // The value is absolute, because a relative path on a clipboard is | |
| 233 | + | // not an address anybody can paste anywhere. | |
| 234 | + | act "Copy link" to local { | |
| 235 | + | copying profile.canonical(); | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | list { | |
| 239 | + | for link in profile.custom_links.iter() { | |
| 240 | + | row link.title.clone() { | |
| 241 | + | secondary link.description.clone(); | |
| 242 | + | // Somewhere else on the web, and the reader is expected | |
| 243 | + | // to come back: external rather than leaving. The | |
| 244 | + | // template said the same thing with `target="_blank" | |
| 245 | + | // rel="ugc nofollow noopener"`. | |
| 246 | + | activate to external link.url.clone(); | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | } unless profile.custom_links.is_empty(); | |
| 250 | + | ||
| 251 | + | section "Projects" unless profile.projects.is_empty(); | |
| 252 | + | list { | |
| 253 | + | for project in profile.projects.iter() { | |
| 254 | + | row project.title.clone() { | |
| 255 | + | meta "{project.project_type} - {project.item_count} items"; | |
| 256 | + | activate to get "/p/{project.slug}" navigating; | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } unless profile.projects.is_empty(); | |
| 260 | + | ||
| 261 | + | section "Collections" unless profile.collections.is_empty(); | |
| 262 | + | list { | |
| 263 | + | for collection in profile.collections.iter() { | |
| 264 | + | row collection.title.clone() { | |
| 265 | + | meta "{collection.item_count} items"; | |
| 266 | + | activate to get | |
| 267 | + | "/c/{profile.user.username}/{collection.slug}" navigating; | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | } unless profile.collections.is_empty(); | |
| 271 | + | ||
| 272 | + | // The footer attribution. Content rather than decoration, for the | |
| 273 | + | // reason `project_blog` keeps its: it is where a reader on a | |
| 274 | + | // creator's page finds out whose platform they are on. | |
| 275 | + | link "Powered by Makenot.work" to get "/" navigating; | |
| 276 | + | } | |
| 155 | 277 | } | |
| 156 | - | ||
| 157 | - | page = page | |
| 158 | - | .with(Node::page(name.to_owned())) | |
| 159 | - | .with(Node::text(user.username.clone())); | |
| 160 | - | ||
| 161 | - | if let Some(bio) = user.bio.as_deref().filter(|bio| !bio.trim().is_empty()) { | |
| 162 | - | page = page.with(Node::text(bio.to_owned())); | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | if profile.is_own_profile { | |
| 166 | - | page = page.with(Node::act( | |
| 167 | - | "Edit Profile", | |
| 168 | - | Action::get("/dashboard?tab=settings").navigating(), | |
| 169 | - | )); | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | // Following is a write, so it needs a session, and a creator does not | |
| 173 | - | // follow themselves. Both were the template's conditions. | |
| 174 | - | if profile.signed_in && !profile.is_own_profile { | |
| 175 | - | page = page.with(super::follow::control( | |
| 176 | - | "user", | |
| 177 | - | profile.user_id, | |
| 178 | - | profile.is_following, | |
| 179 | - | profile.follower_count, | |
| 180 | - | )); | |
| 181 | - | } else if let Some(count) = super::follow::count_only(profile.follower_count) { | |
| 182 | - | page = page.with(count); | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | if profile.tips_enabled { | |
| 186 | - | page = page.with(super::tip::control(&super::tip::Offer { | |
| 187 | - | creator_id: profile.user_id, | |
| 188 | - | project_id: None, | |
| 189 | - | signed_in: profile.signed_in, | |
| 190 | - | })); | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | page = page | |
| 194 | - | .with(Node::act( | |
| 195 | - | "RSS Feed", | |
| 196 | - | Action::get(feed.clone()).navigating(), | |
| 197 | - | )) | |
| 198 | - | // Already sayable: `Act::copies` is "the value this press puts on the | |
| 199 | - | // clipboard", which is the whole of what `data-copy-link` meant. The | |
| 200 | - | // value is absolute, because a relative path on a clipboard is not an | |
| 201 | - | // address anybody can paste anywhere. | |
| 202 | - | .with(Node::Act( | |
| 203 | - | Act::new("Copy link", Action::local()).copying(profile.canonical()), | |
| 204 | - | )); | |
| 205 | - | ||
| 206 | - | if !profile.custom_links.is_empty() { | |
| 207 | - | page = page.with(Node::list(profile.custom_links.iter().map(|link| { | |
| 208 | - | Row::new(link.title.clone()) | |
| 209 | - | .secondary(link.description.clone()) | |
| 210 | - | // Somewhere else on the web, and the reader is expected to come | |
| 211 | - | // back: `External` rather than `Leaving`. The template said the | |
| 212 | - | // same thing with `target="_blank" rel="ugc nofollow noopener"`. | |
| 213 | - | .activate(Action::external(link.url.clone())) | |
| 214 | - | }))); | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | if !profile.projects.is_empty() { | |
| 218 | - | page = page | |
| 219 | - | .with(Node::section("Projects")) | |
| 220 | - | .with(Node::list(profile.projects.iter().map(|project| { | |
| 221 | - | Row::new(project.title.clone()) | |
| 222 | - | .meta(format!( | |
| 223 | - | "{} - {} items", | |
| 224 | - | project.project_type, project.item_count | |
| 225 | - | )) | |
| 226 | - | .activate(Action::get(format!("/p/{}", project.slug)).navigating()) | |
| 227 | - | }))); | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | if !profile.collections.is_empty() { | |
| 231 | - | page = page.with(Node::section("Collections")).with(Node::list( | |
| 232 | - | profile.collections.iter().map(|collection| { | |
| 233 | - | Row::new(collection.title.clone()) | |
| 234 | - | .meta(format!("{} items", collection.item_count)) | |
| 235 | - | .activate( | |
| 236 | - | Action::get(format!("/c/{}/{}", user.username, collection.slug)) | |
| 237 | - | .navigating(), | |
| 238 | - | ) | |
| 239 | - | }), | |
| 240 | - | )); | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | // The footer attribution. Content rather than decoration, for the reason | |
| 244 | - | // `project_blog` keeps its: it is where a reader on a creator's page finds | |
| 245 | - | // out whose platform they are on. | |
| 246 | - | page = page.with(Node::Link { | |
| 247 | - | text: "Powered by Makenot.work".to_owned(), | |
| 248 | - | action: Action::get("/").navigating(), | |
| 249 | - | }); | |
| 250 | - | ||
| 251 | - | Described::single(format!("{name} - Makenotwork")) | |
| 252 | - | .measured(MEASURE) | |
| 253 | - | .documented( | |
| 254 | - | Document::default() | |
| 255 | - | .classed(crate::shell::body_class(MEASURE, &["user-page"])) | |
| 256 | - | // Tier 0: the creator's own primitive-layer override, written | |
| 257 | - | // last in the head and unlayered, which is where the | |
| 258 | - | // hand-written block put it. | |
| 259 | - | .styled(theme_css.to_owned()), | |
| 260 | - | ) | |
| 261 | - | .summarised(profile.summary()) | |
| 262 | - | .illustrated(profile.image()) | |
| 263 | - | .canonical_at(profile.canonical()) | |
| 264 | - | .about(quasi_router::SocialKind::Profile) | |
| 265 | - | .syndicating(Feed::new(FeedKind::Rss, format!("{name} - RSS Feed"), feed)) | |
| 266 | - | .with(page) | |
| 267 | 278 | } | |
| 268 | 279 | ||
| 269 | 280 | /// The document this screen is drawn in. |
| @@ -41,9 +41,9 @@ | |||
| 41 | 41 | //! parameter and answers a redirect to the canonical path. One address for the | |
| 42 | 42 | //! content, one address for the control, and the reader lands on the first. | |
| 43 | 43 | ||
| 44 | - | use makeover_layout as layout; | |
| 45 | - | use quasi_router::screen::{Field, Row}; | |
| 46 | - | use quasi_router::{Action, Node, RegionKind, Run, Slot}; | |
| 44 | + | use quasi_declare::declare; | |
| 45 | + | use quasi_router::Action; | |
| 46 | + | use quasi_router::screen::Choice; | |
| 47 | 47 | ||
| 48 | 48 | use crate::git::{Breadcrumb, RefInfo}; | |
| 49 | 49 | ||
| @@ -75,32 +75,127 @@ | |||
| 75 | 75 | pub refs: &'a [RefInfo], | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | /// One place this repository has, as the strip draws it. | |
| 79 | + | /// | |
| 80 | + | /// Named members rather than a tuple, for `policy`'s reason: a description | |
| 81 | + | /// names what it draws, and `.1` is not a name. Whether the reader is here is | |
| 82 | + | /// decided in [`Nav::tabs`] rather than at the row, because `commit` is one of | |
| 83 | + | /// the commits Commits lists and the reader is in the same place either way -- | |
| 84 | + | /// a fact about the set of tabs, not about one of them. | |
| 85 | + | struct Tab { | |
| 86 | + | /// What the tab is called, count included where there is one. | |
| 87 | + | label: String, | |
| 88 | + | /// Where it goes. | |
| 89 | + | route: String, | |
| 90 | + | /// Whether the reader is there. | |
| 91 | + | current: bool, | |
| 92 | + | } | |
| 93 | + | ||
| 78 | 94 | impl Nav<'_> { | |
| 79 | 95 | /// The repository's own prefix, which every address here is under. | |
| 80 | 96 | fn base(&self) -> String { | |
| 81 | 97 | format!("/git/{}/{}", self.owner, self.repo) | |
| 82 | 98 | } | |
| 83 | - | } | |
| 84 | 99 | ||
| 85 | - | /// The bar, for a screen that owns its whole document. | |
| 86 | - | /// | |
| 87 | - | /// The chooser is drawn only where there is something to choose. Issues, | |
| 88 | - | /// a single issue and settings are facts about the repository rather than | |
| 89 | - | /// about a ref, and their handlers never open the repository at all, so | |
| 90 | - | /// offering a ref there would mean walking the object database for a control | |
| 91 | - | /// that changes nothing on the page. They pass no refs and get the strip. | |
| 92 | - | #[must_use] | |
| 93 | - | pub fn region(nav: &Nav<'_>) -> Node { | |
| 94 | - | // The bar is one row that wraps below the breakpoint. Nothing in it may | |
| 95 | - | // drop: the chooser and the strip are the only ways off this page. | |
| 96 | - | let mut bar = Run::new(layout::Fallback::Wrap); | |
| 97 | - | ||
| 98 | - | if !nav.refs.is_empty() { | |
| 99 | - | bar = bar.beside(Node::field(chooser(nav)), layout::Priority::Essential); | |
| 100 | + | /// Where the chooser's answer goes. See the module header. | |
| 101 | + | fn ref_path(&self) -> String { | |
| 102 | + | format!("{}/ref", self.base()) | |
| 100 | 103 | } | |
| 101 | 104 | ||
| 102 | - | let bar = bar.beside(strip(nav), layout::Priority::Essential); | |
| 103 | - | Node::Region(Slot::new(REGION, RegionKind::Group).across(bar)) | |
| 105 | + | /// Whether there is anything to choose between. | |
| 106 | + | fn has_refs(&self) -> bool { | |
| 107 | + | !self.refs.is_empty() | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// The six places this repository has, in the order the strip draws them. | |
| 111 | + | fn tabs(&self) -> Vec<Tab> { | |
| 112 | + | let base = self.base(); | |
| 113 | + | let current = | |
| 114 | + | |key: &str| self.active_tab == key || (key == "commits" && self.active_tab == "commit"); | |
| 115 | + | ||
| 116 | + | let mut tabs = vec![ | |
| 117 | + | Tab { | |
| 118 | + | label: "Files".to_owned(), | |
| 119 | + | route: format!("{base}/tree/{}", self.current_ref), | |
| 120 | + | current: current("files"), | |
| 121 | + | }, | |
| 122 | + | Tab { | |
| 123 | + | label: "Commits".to_owned(), | |
| 124 | + | route: format!("{base}/commits/{}", self.current_ref), | |
| 125 | + | current: current("commits"), | |
| 126 | + | }, | |
| 127 | + | Tab { | |
| 128 | + | label: "Tags".to_owned(), | |
| 129 | + | route: format!("{base}/tags"), | |
| 130 | + | current: current("tags"), | |
| 131 | + | }, | |
| 132 | + | Tab { | |
| 133 | + | label: "Notes".to_owned(), | |
| 134 | + | route: format!("{base}/notes"), | |
| 135 | + | current: current("notes"), | |
| 136 | + | }, | |
| 137 | + | Tab { | |
| 138 | + | // The count is part of the name a reader reads, and there is no | |
| 139 | + | // second thing to say: a tab reading `Issues` when there are | |
| 140 | + | // none and `Issues (3)` when there are three is one label. | |
| 141 | + | label: if self.open_issue_count > 0 { | |
| 142 | + | format!("Issues ({})", self.open_issue_count) | |
| 143 | + | } else { | |
| 144 | + | "Issues".to_owned() | |
| 145 | + | }, | |
| 146 | + | route: format!("{base}/issues"), | |
| 147 | + | current: current("issues"), | |
| 148 | + | }, | |
| 149 | + | ]; | |
| 150 | + | if self.is_owner { | |
| 151 | + | tabs.push(Tab { | |
| 152 | + | label: "Settings".to_owned(), | |
| 153 | + | route: format!("{base}/settings"), | |
| 154 | + | current: current("settings"), | |
| 155 | + | }); | |
| 156 | + | } | |
| 157 | + | tabs | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// What a ref is called in the chooser. | |
| 162 | + | /// | |
| 163 | + | /// A tag reads as `tag: v1.2.0` and a branch as its bare name, which is the | |
| 164 | + | /// distinction the shipped `<option>` drew and the only one the list makes. The | |
| 165 | + | /// submitted value is the ref name either way, so the label carrying the word | |
| 166 | + | /// costs nothing. | |
| 167 | + | /// | |
| 168 | + | /// A supplier handing back a `String` rather than a `Choice`, which is the | |
| 169 | + | /// smallest type that works and keeps it out of the population. | |
| 170 | + | fn ref_label(info: &RefInfo) -> String { | |
| 171 | + | if info.is_branch { | |
| 172 | + | info.name.clone() | |
| 173 | + | } else { | |
| 174 | + | format!("tag: {}", info.name) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | declare! { | |
| 179 | + | /// The bar, for a screen that owns its whole document. | |
| 180 | + | /// | |
| 181 | + | /// One row that wraps below the breakpoint. Nothing in it may drop: the | |
| 182 | + | /// chooser and the strip are the only ways off this page. | |
| 183 | + | /// | |
| 184 | + | /// The chooser is drawn only where there is something to choose. Issues, a | |
| 185 | + | /// single issue and settings are facts about the repository rather than | |
| 186 | + | /// about a ref, and their handlers never open the repository at all, so | |
| 187 | + | /// offering a ref there would mean walking the object database for a | |
| 188 | + | /// control that changes nothing on the page. They pass no refs and get the | |
| 189 | + | /// strip. | |
| 190 | + | #[must_use] | |
| 191 | + | pub shape region(nav: &Nav<'_>) -> Node; | |
| 192 | + | ||
| 193 | + | region REGION as Group { | |
| 194 | + | across Wrap { | |
| 195 | + | beside Essential include chooser(nav) when nav.has_refs(); | |
| 196 | + | beside Essential include strip(nav); | |
| 197 | + | } | |
| 198 | + | } | |
| 104 | 199 | } | |
| 105 | 200 | ||
| 106 | 201 | /// The markup, for an Askama template to drop in. | |
| @@ -112,154 +207,96 @@ | |||
| 112 | 207 | quasi_webview::Webview::new().fragment(®ion(nav)) | |
| 113 | 208 | } | |
| 114 | 209 | ||
| 115 | - | /// The branch and tag chooser. | |
| 116 | - | /// | |
| 117 | - | /// A tag reads as `tag: v1.2.0` and a branch as its bare name, which is the | |
| 118 | - | /// distinction the shipped `<option>` drew and the only one the list makes. The | |
| 119 | - | /// submitted value is the ref name either way, so the label carrying the word | |
| 120 | - | /// costs nothing: [`Choice`](quasi_router::screen::Choice) has been two strings | |
| 121 | - | /// since it existed for exactly this. | |
| 122 | - | fn chooser(nav: &Nav<'_>) -> Field { | |
| 123 | - | use quasi_router::screen::Choice; | |
| 210 | + | declare! { | |
| 211 | + | /// The branch and tag chooser. | |
| 212 | + | /// | |
| 213 | + | /// One question with many answers, and a list of two hundred branches drawn | |
| 214 | + | /// as links would be the page. Its answer goes somewhere, so it writes a | |
| 215 | + | /// navigating read to the join route, which redirects to the canonical tree | |
| 216 | + | /// address. See the module header. | |
| 217 | + | /// | |
| 218 | + | /// The options accrete one per ref, which is what `Field::option` is for: | |
| 219 | + | /// `Field::options` takes the whole list and the form has no expression to | |
| 220 | + | /// hold one in. | |
| 221 | + | shape chooser(nav: &Nav<'_>) -> Field; | |
| 124 | 222 | ||
| 125 | - | let options = nav | |
| 126 | - | .refs | |
| 127 | - | .iter() | |
| 128 | - | .map(|r| { | |
| 129 | - | if r.is_branch { | |
| 130 | - | Choice::plain(&r.name) | |
| 131 | - | } else { | |
| 132 | - | Choice::new(&r.name, format!("tag: {}", r.name)) | |
| 133 | - | } | |
| 134 | - | }) | |
| 135 | - | .collect(); | |
| 136 | - | ||
| 137 | - | Field::select(CHOSEN, "Branch or tag", options) | |
| 138 | - | .value(nav.current_ref) | |
| 139 | - | .writes(Action::get(format!("{}/ref", nav.base())).navigating()) | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | /// The six places this repository has. | |
| 143 | - | fn strip(nav: &Nav<'_>) -> Node { | |
| 144 | - | let base = nav.base(); | |
| 145 | - | ||
| 146 | - | // `commit` is a single commit, which is one of the commits the Commits tab | |
| 147 | - | // lists: the shipped markup marked the tab for both and the reader is in | |
| 148 | - | // the same place either way. | |
| 149 | - | let current = | |
| 150 | - | |key: &str| nav.active_tab == key || (key == "commits" && nav.active_tab == "commit"); | |
| 151 | - | ||
| 152 | - | let mut tabs = vec![ | |
| 153 | - | ( | |
| 154 | - | "files", | |
| 155 | - | "Files".to_owned(), | |
| 156 | - | format!("{base}/tree/{}", nav.current_ref), | |
| 157 | - | ), | |
| 158 | - | ( | |
| 159 | - | "commits", | |
| 160 | - | "Commits".to_owned(), | |
| 161 | - | format!("{base}/commits/{}", nav.current_ref), | |
| 162 | - | ), | |
| 163 | - | ("tags", "Tags".to_owned(), format!("{base}/tags")), | |
| 164 | - | ("notes", "Notes".to_owned(), format!("{base}/notes")), | |
| 165 | - | ( | |
| 166 | - | "issues", | |
| 167 | - | // The count is part of the name a reader reads, and there is no | |
| 168 | - | // second thing to say: a tab reading `Issues` when there are none | |
| 169 | - | // and `Issues (3)` when there are three is one label. | |
| 170 | - | if nav.open_issue_count > 0 { | |
| 171 | - | format!("Issues ({})", nav.open_issue_count) | |
| 172 | - | } else { | |
| 173 | - | "Issues".to_owned() | |
| 174 | - | }, | |
| 175 | - | format!("{base}/issues"), | |
| 176 | - | ), | |
| 177 | - | ]; | |
| 178 | - | if nav.is_owner { | |
| 179 | - | tabs.push(( | |
| 180 | - | "settings", | |
| 181 | - | "Settings".to_owned(), | |
| 182 | - | format!("{base}/settings"), | |
| 183 | - | )); | |
| 223 | + | field Select CHOSEN "Branch or tag" { | |
| 224 | + | for info in nav.refs.iter() { | |
| 225 | + | option Choice::new(&info.name, ref_label(info)); | |
| 226 | + | } | |
| 227 | + | value nav.current_ref; | |
| 228 | + | writes Action::get(nav.ref_path()).navigating(); | |
| 184 | 229 | } | |
| 185 | - | ||
| 186 | - | Node::list( | |
| 187 | - | tabs.into_iter() | |
| 188 | - | .map(|(key, label, route)| { | |
| 189 | - | let mut row = Row::new(label).activate(Action::get(route).navigating()); | |
| 190 | - | row.current = current(key); | |
| 191 | - | row | |
| 192 | - | }) | |
| 193 | - | .collect::<Vec<_>>(), | |
| 194 | - | ) | |
| 195 | 230 | } | |
| 196 | 231 | ||
| 197 | - | /// The line that says which repository this is. | |
| 198 | - | /// | |
| 199 | - | /// Two links and a separator in the shipped markup, and only the links are | |
| 200 | - | /// described: a `/` between an owner and a repository is punctuation the reader | |
| 201 | - | /// never presses, which is presentation and stays in the stylesheet. Named | |
| 202 | - | /// rather than headed, because [`Node::Heading`] carries text and this carries | |
| 203 | - | /// two addresses. | |
| 204 | - | #[must_use] | |
| 205 | - | pub fn heading(owner: &str, repo: &str) -> Node { | |
| 206 | - | Node::Region( | |
| 207 | - | Slot::new("git-repo-name", RegionKind::Group) | |
| 208 | - | .named(format!("{owner} / {repo}")) | |
| 209 | - | .across( | |
| 210 | - | Run::new(layout::Fallback::Wrap) | |
| 211 | - | .beside( | |
| 212 | - | Node::Link { | |
| 213 | - | text: owner.to_owned(), | |
| 214 | - | action: Action::get(format!("/git/{owner}")).navigating(), | |
| 215 | - | }, | |
| 216 | - | layout::Priority::Essential, | |
| 217 | - | ) | |
| 218 | - | .beside( | |
| 219 | - | Node::Link { | |
| 220 | - | text: repo.to_owned(), | |
| 221 | - | action: Action::get(format!("/git/{owner}/{repo}")).navigating(), | |
| 222 | - | }, | |
| 223 | - | layout::Priority::Essential, | |
| 224 | - | ), | |
| 225 | - | ), | |
| 226 | - | ) | |
| 227 | - | } | |
| 232 | + | declare! { | |
| 233 | + | /// The six places this repository has. | |
| 234 | + | /// | |
| 235 | + | /// Rows of the same kind of thing, each going somewhere, one of them | |
| 236 | + | /// current. That is the whole of what the six `<a>` elements said, | |
| 237 | + | /// `is-selected` included, and `current` is the member for it -- the app's | |
| 238 | + | /// own pointer at where the reader is, which is exactly what `aria-current` | |
| 239 | + | /// exists to announce. | |
| 240 | + | shape strip(nav: &Nav<'_>) -> Node; | |
| 228 | 241 | ||
| 229 | - | /// The path from the repository root down to what is being shown. | |
| 230 | - | /// | |
| 231 | - | /// The last crumb is the thing itself and goes nowhere, which is what | |
| 232 | - | /// [`Breadcrumb::is_link`] already says. The separators are the stylesheet's, | |
| 233 | - | /// for [`heading`]'s reason. | |
| 234 | - | #[must_use] | |
| 235 | - | pub fn breadcrumb(owner: &str, repo: &str, current_ref: &str, crumbs: &[Breadcrumb]) -> Node { | |
| 236 | - | let tree = format!("/git/{owner}/{repo}/tree/{current_ref}"); | |
| 237 | - | ||
| 238 | - | let mut trail = Run::new(layout::Fallback::Wrap).beside( | |
| 239 | - | Node::Link { | |
| 240 | - | text: repo.to_owned(), | |
| 241 | - | action: Action::get(tree.clone()).navigating(), | |
| 242 | - | }, | |
| 243 | - | layout::Priority::Essential, | |
| 244 | - | ); | |
| 245 | - | ||
| 246 | - | for crumb in crumbs { | |
| 247 | - | let node = if crumb.is_link { | |
| 248 | - | Node::Link { | |
| 249 | - | text: crumb.name.clone(), | |
| 250 | - | action: Action::get(format!("{tree}/{}", crumb.path)).navigating(), | |
| 242 | + | list { | |
| 243 | + | for tab in nav.tabs() { | |
| 244 | + | row tab.label { | |
| 245 | + | current tab.current; | |
| 246 | + | activate to get tab.route navigating; | |
| 251 | 247 | } | |
| 252 | - | } else { | |
| 253 | - | Node::text(crumb.name.clone()) | |
| 254 | - | }; | |
| 255 | - | trail = trail.beside(node, layout::Priority::Essential); | |
| 248 | + | } | |
| 256 | 249 | } | |
| 250 | + | } | |
| 257 | 251 | ||
| 258 | - | Node::Region( | |
| 259 | - | Slot::new("git-breadcrumb", RegionKind::Group) | |
| 260 | - | .named("Path") | |
| 261 | - | .across(trail), | |
| 262 | - | ) | |
| 252 | + | declare! { | |
| 253 | + | /// The line that says which repository this is. | |
| 254 | + | /// | |
| 255 | + | /// Two links and a separator in the shipped markup, and only the links are | |
| 256 | + | /// described: a `/` between an owner and a repository is punctuation the | |
| 257 | + | /// reader never presses, which is presentation and stays in the stylesheet. | |
| 258 | + | /// Named rather than headed, because a heading carries text and this | |
| 259 | + | /// carries two addresses. | |
| 260 | + | #[must_use] | |
| 261 | + | pub shape heading(owner: &str, repo: &str) -> Node; | |
| 262 | + | ||
| 263 | + | region "git-repo-name" as Group { | |
| 264 | + | named "{owner} / {repo}"; | |
| 265 | + | across Wrap { | |
| 266 | + | beside Essential link owner to get "/git/{owner}" navigating; | |
| 267 | + | beside Essential link repo to get "/git/{owner}/{repo}" navigating; | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | declare! { | |
| 273 | + | /// The path from the repository root down to what is being shown. | |
| 274 | + | /// | |
| 275 | + | /// The last crumb is the thing itself and goes nowhere, which is what | |
| 276 | + | /// `Breadcrumb::is_link` already says. The separators are the stylesheet's, | |
| 277 | + | /// for [`heading`]'s reason. | |
| 278 | + | #[must_use] | |
| 279 | + | pub shape breadcrumb( | |
| 280 | + | owner: &str, | |
| 281 | + | repo: &str, | |
| 282 | + | current_ref: &str, | |
| 283 | + | crumbs: &[Breadcrumb], | |
| 284 | + | ) -> Node; | |
| 285 | + | ||
| 286 | + | let tree = "/git/{owner}/{repo}/tree/{current_ref}"; | |
| 287 | + | ||
| 288 | + | region "git-breadcrumb" as Group { | |
| 289 | + | named "Path"; | |
| 290 | + | across Wrap { | |
| 291 | + | beside Essential link repo to get tree.clone() navigating; | |
| 292 | + | for crumb in crumbs.iter() { | |
| 293 | + | beside Essential given crumb.is_link { | |
| 294 | + | true -> link crumb.name.clone() to get "{tree}/{crumb.path}" navigating; | |
| 295 | + | otherwise -> text crumb.name.clone(); | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | } | |
| 263 | 300 | } | |
| 264 | 301 | ||
| 265 | 302 | #[cfg(test)] |