Skip to main content

max / makenotwork

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