Skip to main content

max / makenotwork

server: a writes-only nest, and the members panel on it Implements 03c0977b, ruled today (Max), option (a). The hole it fills. A panel whose route answers a conditional GET cannot be a mounted screen, because mount has no way to say "304 if the cache generation has not moved". So it stays a fill on its Askama handler -- and a fill had no nest, so it had nowhere to put its writes, so its controls kept addressing API routes that answer 200 or a toast and cannot name the region they changed. The patch for that was data-after, the private dispatcher vocabulary this conversion exists to retire. mount hardcoded .get("/", screen) as its first line. Both it and the new writes_only now go through nest(), which takes the root GET as an Option. That is the whole mechanism. project_members is the first panel on it, and the reason it was found. Its read keeps the Askama route and its ETag; its two writes are described routes under a fixed prefix with the ids in the inner paths, answering Response::fragment naming the panel's region. Both of its data-after sites are gone. The API routes stay registered for API consumers, exactly as /api/users/me/ssh-keys/{id} did when ssh_keys moved its controls off it. One entry point rather than the fill/fragment pair the other panels have: project_monetization.html includes this section rather than a strip drawing a region around it, so nothing else emits the id. The add form keeps its sentence. add_project_member answered a toast that is not decoration -- when the collaborator settles in a different currency it says Stripe's conversion comes out of their share, and that is the only moment the owner hears it before money moves. It rides in the fragment as a toast node now. Two things a cell cannot hold, both handled rather than dropped: the member's @handle joins the display name in the value, and the "reserved, earning nothing until they accept" sentence is said once under the table instead of inside every unaccepted row's split cell. 203 lib tests green, fmt and clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 23:03 UTC
Signed with PGP, not checked
Commit: 14254786de63dd0f9c5eba3836ed8ffc16527bd8
Parent: 1a837bd
7 files changed, +571 insertions, -127 deletions
@@ -50,6 +50,7 @@
50 50 pub mod payout_summary;
51 51 pub mod project_analytics;
52 52 pub mod project_content;
53 + pub mod project_members;
53 54 pub mod project_overview;
54 55 pub mod project_tabs;
55 56 pub mod rich_field;
@@ -316,6 +317,13 @@
316 317 payout_summary::PATH,
317 318 mount(app, payout_summary::screen, &[], payout_summary::renderer),
318 319 ),
320 + // A nest that answers no address of its own: the Members panel is read
321 + // through its Askama route, which keeps a conditional GET, and only its
322 + // writes are described. `03c0977b`; see `writes_only`.
323 + (
324 + project_members::NEST,
325 + writes_only(app, project_members::WRITES, project_members::renderer),
326 + ),
319 327 (
320 328 forum_memberships::SETTINGS_PATH,
321 329 mount(
@@ -363,7 +371,60 @@
363 371 writes: &[(quasi_router::Method, &'static str, Screen)],
364 372 renderer: fn(&Viewer) -> quasi_webview::Webview,
365 373 ) -> axum::Router {
366 - let mut quasi = quasi_router::Router::<Viewer>::new().get("/", screen);
374 + nest(app, Some(screen), writes, renderer)
375 + }
376 +
377 + /// A nest that serves writes and answers no address of its own.
378 + ///
379 + /// `03c0977b`, ruled 2026-08-26 (Max), option (a). The hole it fills: a panel
380 + /// whose route answers a conditional GET cannot be a mounted screen, because
381 + /// [`mount`] has no way to say "304 if the cache generation has not moved". So
382 + /// it stays a fill on its Askama handler -- and a fill has no nest, so it had
383 + /// nowhere to put its writes, so its controls kept addressing API routes that
384 + /// answer 200 or 204 and cannot name the region they changed. The patch for
385 + /// that was `data-after`, the private dispatcher vocabulary in
386 + /// `frontend/src/core/dispatch.ts` that this conversion exists to retire.
387 + ///
388 + /// This is the other half of such a panel: the read keeps its Askama route and
389 + /// its ETag, and the writes get described routes that answer
390 + /// `Response::Fragment` naming the panel's region, exactly as a mounted
391 + /// screen's do.
392 + ///
393 + /// # The cost, stated once rather than per panel
394 + ///
395 + /// One panel is then served by two routers, and the read and the write are no
396 + /// longer visible in one place. That is the trade: the alternative was nine
397 + /// tabs converting their markup while keeping their JS, which lowers no seal
398 + /// and is not what S4 is for.
399 + ///
400 + /// # The address is a fixed prefix, and the ids go inside it
401 + ///
402 + /// A nest is mounted at a fixed path, which is also why `project_analytics` is
403 + /// a fill rather than a mounted screen. Path parameters live in the inner
404 + /// router, which does support them: `ssh_keys` already registers `/keys/{id}`
405 + /// and `/tokens/{id}` under its own nest. So a writes-only nest carries its ids
406 + /// in the inner paths and does not reuse the API route's address. That API
407 + /// route stays for API consumers, exactly as `/api/users/me/ssh-keys/{id}` did
408 + /// when `ssh_keys` moved its controls off it.
409 + fn writes_only(
410 + app: &AppState,
411 + writes: &[(quasi_router::Method, &'static str, Screen)],
412 + renderer: fn(&Viewer) -> quasi_webview::Webview,
413 + ) -> axum::Router {
414 + nest(app, None, writes, renderer)
415 + }
416 +
417 + /// The router both of the above build, with or without a root GET.
418 + fn nest(
419 + app: &AppState,
420 + screen: Option<Screen>,
421 + writes: &[(quasi_router::Method, &'static str, Screen)],
422 + renderer: fn(&Viewer) -> quasi_webview::Webview,
423 + ) -> axum::Router {
424 + let mut quasi = quasi_router::Router::<Viewer>::new();
425 + if let Some(screen) = screen {
426 + quasi = quasi.get("/", screen);
427 + }
367 428 for (method, path, handler) in writes {
368 429 quasi = match method {
369 430 quasi_router::Method::Delete => quasi.delete(path, *handler),
@@ -229,7 +229,6 @@
229 229 ProjectSettingsTabTemplate,
230 230 ProjectCodeTabTemplate,
231 231 ProjectSubscriptionsTabTemplate,
232 - ProjectMembersTabTemplate,
233 232 ProjectMonetizationTabTemplate,
234 233 ItemEditRowTemplate,
235 234 // Admin partials
@@ -517,17 +517,6 @@
517 517 pub stripe_connected: bool,
518 518 }
519 519
520 - /// Dashboard members tab partial for managing project members and revenue splits.
521 - #[derive(Template)]
522 - #[template(path = "partials/tabs/project_members.html")]
523 - #[allow(dead_code)]
524 - pub struct ProjectMembersTabTemplate {
525 - pub project_id: String,
526 - pub project_slug: String,
527 - pub members: Vec<ProjectMemberRow>,
528 - pub owner_split: i64,
529 - }
530 -
531 520 /// Combined monetization tab: tiers, promo codes, and team splits.
532 521 #[derive(Template)]
533 522 #[template(path = "partials/tabs/project_monetization.html")]
@@ -6,4 +6,6 @@
6 6
7 7 <hr class="section-divider">
8 8
9 - {% include "partials/tabs/project_members.html" %}
9 + {#- The team-splits section is described. It emits its own `#project-members`
10 + region, which is what its writes answer into: `crate::quasi::project_members`. -#}
11 + {{ crate::quasi::project_members::section(members, *owner_split, project_id.as_str())|safe }}
@@ -15,9 +15,8 @@
15 15 helpers,
16 16 templates::{
17 17 LinkedRepoView, ProjectCodeTabTemplate, ProjectContentTabTemplate,
18 - ProjectMembersTabTemplate, ProjectMonetizationTabTemplate, ProjectOverviewTabTemplate,
19 - ProjectSettingsTabTemplate, ProjectSubscriptionsTabTemplate, ProjectSyncKitTabTemplate,
20 - RepoCollaboratorView,
18 + ProjectMonetizationTabTemplate, ProjectOverviewTabTemplate, ProjectSettingsTabTemplate,
19 + ProjectSubscriptionsTabTemplate, ProjectSyncKitTabTemplate, RepoCollaboratorView,
21 20 },
22 21 types::{
23 22 BlogPostDashboardRow, ContentItem, Project, ProjectMemberRow, PromoCodeRow, StatCard,
@@ -620,12 +619,11 @@
620 619
621 620 Ok(helpers::with_etag(
622 621 generation,
623 - ProjectMembersTabTemplate {
624 - project_id: db_project.id.to_string(),
625 - project_slug: db_project.slug.to_string(),
626 - members,
622 + axum::response::Html(crate::quasi::project_members::section(
623 + &members,
627 624 owner_split,
628 - },
625 + &db_project.id.to_string(),
626 + )),
629 627 ))
630 628 }
631 629
@@ -1,0 +1,559 @@
1 + //! The project dashboard's Members & Payouts panel, described.
2 + //!
3 + //! The first panel on the writes-only nest (`03c0977b`, ruled 2026-08-26 by
4 + //! Max, option (a)), and the reason that mechanism exists.
5 + //!
6 + //! # Two routers, one panel, and why
7 + //!
8 + //! `project_tab_members` answers a conditional GET through
9 + //! `resolve_project_etag`, and [`super::mount`] cannot say "304 if the cache
10 + //! generation has not moved". So the **read** stays on its Askama handler and
11 + //! this module is its fill, exactly as [`super::project_overview`] is.
12 + //!
13 + //! A fill has no nest, so before `03c0977b` it had nowhere to put its
14 + //! **writes**, and its two controls addressed API routes that answer a toast
15 + //! and cannot name the region they changed. What patched over that was
16 + //! `data-after="reset refresh"` with the panel's address and target passed
17 + //! positionally in `data-arg` and `data-arg2` -- the private dispatcher
18 + //! vocabulary in `frontend/src/core/dispatch.ts` that this conversion exists to
19 + //! retire.
20 + //!
21 + //! So: the read is a fill on the Askama route, and the writes are
22 + //! [`super::writes_only`] under [`NEST`]. Both render through [`body`], so the
23 + //! panel a write answers with is the same panel the read draws.
24 + //!
25 + //! # The API routes stay, and are not what this addresses
26 + //!
27 + //! `POST /api/projects/{id}/members` and
28 + //! `DELETE /api/projects/{project_id}/members/{user_id}` keep working for API
29 + //! consumers. This panel's controls no longer call them, the same way
30 + //! `ssh_keys` stopped calling `/api/users/me/ssh-keys/{id}` without deleting
31 + //! it.
32 + //!
33 + //! The ids live in the inner paths because a nest is mounted at a fixed prefix.
34 + //! See [`super::writes_only`].
35 + //!
36 + //! # The add form keeps its sentence, and it is worth keeping
37 + //!
38 + //! `add_project_member` answers a toast that is not decoration: when the
39 + //! collaborator settles in a different currency from the project, it says so,
40 + //! and says Stripe's conversion comes out of their share. That is the only
41 + //! moment the owner is in a position to hear it before money moves. A described
42 + //! answer carries it as [`Node::toast`] in the fragment rather than losing it
43 + //! with the endpoint.
44 + //!
45 + //! # What the description says that the markup did not
46 + //!
47 + //! The split cell packed three facts into one `<td>`: the percentage, an
48 + //! "Invited" badge, and a sentence explaining what invited means. Said as a
49 + //! cell with a token and a meta line, the sentence stops being markup that only
50 + //! appears inside a conditional inside a table cell.
51 +
52 + use makeover_layout as layout;
53 + use quasi_router::screen::{Act, Cell, Cells, Column, Field, Tag};
54 + use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot};
55 + use quasi_webview::Webview;
56 +
57 + use super::Viewer;
58 + use crate::db;
59 + use crate::types::ProjectMemberRow;
60 +
61 + /// The region the answer replaces, keeping the id the page already used.
62 + pub const REGION: &str = "project-members";
63 +
64 + /// Where the writes live. A fixed prefix; the ids are in the inner paths.
65 + pub const NEST: &str = "/dashboard/described/project-members";
66 +
67 + /// Adding a collaborator, relative to [`NEST`].
68 + const ADD: &str = "/{project}";
69 +
70 + /// Removing one, relative to [`NEST`].
71 + const REMOVE: &str = "/{project}/{user}";
72 +
73 + /// The writes this panel serves. Registered under [`NEST`] by
74 + /// [`super::writes_only`].
75 + pub const WRITES: &[(Method, &str, super::Screen)] =
76 + &[(Method::Post, ADD, add), (Method::Delete, REMOVE, remove)];
77 +
78 + /// The section, carrying its own region id.
79 + ///
80 + /// One entry point rather than the `fill`/`fragment` pair the other converted
81 + /// panels have, and the composite is why. `project_monetization.html` includes
82 + /// this section rather than a strip drawing a region around it, so nothing else
83 + /// emits the id: this does, for the first render and for a write's answer
84 + /// alike. Two spellings of one id is how they drift.
85 + #[must_use]
86 + pub fn section(members: &[ProjectMemberRow], owner_split: i64, project: &str) -> String {
87 + use quasi_axum::Serves as _;
88 +
89 + Webview::new().fragment(&pane(members, owner_split, project, None))
90 + }
91 +
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);
97 + }
98 + Node::Region(slot)
99 + }
100 +
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 + ]),
122 + ];
123 +
124 + if let Some(said) = said {
125 + out.push(Node::toast(layout::Tone::Success, said));
126 + }
127 +
128 + out.push(add_form(project));
129 +
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));
139 +
140 + // The template repeated this inside every unaccepted row's split cell. A
141 + // cell is a run of leaves and has no second line, and saying it once under
142 + // the table is better anyway: it is one fact about the Invited badge, not a
143 + // 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 + ));
149 + }
150 +
151 + out
152 + }
153 +
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 +
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 + }
196 +
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 + fn table(members: &[ProjectMemberRow], project: &str) -> Node {
206 + Node::Table {
207 + columns: vec![
208 + Column::new("Member")
209 + .width(layout::Width::Fill)
210 + .priority(layout::Priority::Essential),
211 + Column::new("Role").width(layout::Width::Content),
212 + Column::new("Split").width(layout::Width::Content),
213 + Column::new("Stripe").width(layout::Width::Content),
214 + Column::new("Added")
215 + .width(layout::Width::Content)
216 + .priority(layout::Priority::Optional),
217 + Column::new("").width(layout::Width::Content),
218 + ],
219 + rows: members.iter().map(|m| row(m, project)).collect(),
220 + more: None,
221 + }
222 + }
223 +
224 + /// One collaborator.
225 + fn row(member: &ProjectMemberRow, project: &str) -> Cells {
226 + let shown = member.display_name.as_deref().unwrap_or(&member.username);
227 +
228 + // The template packed the percentage, the badge and the sentence into one
229 + // `<td>`. Three facts, said as three.
230 + let mut split = Cell::new(format!("{}%", member.split_percent));
231 + if !member.accepted {
232 + let mut invited = Tag::badge("Invited");
233 + invited.tone = layout::Tone::Warning;
234 + split = split.token(invited);
235 + }
236 +
237 + let mut stripe = Tag::badge(if member.stripe_connected {
238 + "Connected"
239 + } else {
240 + "Not connected"
241 + });
242 + stripe.tone = if member.stripe_connected {
243 + layout::Tone::Success
244 + } else {
245 + layout::Tone::Warning
246 + };
247 +
248 + Cells::new([
249 + // The template drew the display name and `@username` as two lines in
250 + // one `<td>`. A cell is a run of leaves and has no second line, so the
251 + // handle rides in the value where a reader still sees it.
252 + Cell::new(format!("{shown} (@{})", member.username))
253 + .activate(Action::get(format!("/u/{}", member.username)).navigating()),
254 + Cell::new(member.role.clone()),
255 + split,
256 + Cell::new(String::new()).token(stripe),
257 + Cell::new(member.added_at.clone()),
258 + Cell::new(String::new()).act(
259 + Act::new(
260 + "Remove",
261 + Action::delete(format!("{NEST}/{project}/{}", member.user_id)),
262 + )
263 + .tone(layout::Tone::Danger)
264 + .confirm(format!("Remove {shown} from this project?")),
265 + ),
266 + ])
267 + }
268 +
269 + /// The project this write is about, and the reader's right to touch it.
270 + fn owned(viewer: &Viewer, captures: &quasi_router::Params) -> Result<db::ProjectId, RouteError> {
271 + let project: db::ProjectId = captures
272 + .get("project")
273 + .and_then(|id| id.parse::<uuid::Uuid>().ok())
274 + .ok_or_else(|| RouteError::not_found("no such project"))?
275 + .into();
276 +
277 + // The same check `routes::api::verify_project_ownership` makes, and it is
278 + // not optional here: a nest is authenticated but says nothing about which
279 + // projects this reader owns. Answered as not-found rather than denied, so
280 + // the nest does not confirm that a project id exists to someone who does
281 + // not own it.
282 + let owned = viewer
283 + .block_on(db::projects::get_project_by_id(&viewer.app.db, project))
284 + .map_err(|_| RouteError::internal("that project could not be read"))?
285 + .ok_or_else(|| RouteError::not_found("no such project"))?;
286 +
287 + if owned.user_id != viewer.user.id {
288 + return Err(RouteError::not_found("no such project"));
289 + }
290 + Ok(project)
291 + }
292 +
293 + /// The panel as it now stands, for a write to answer with.
294 + fn answer(
295 + viewer: &Viewer,
296 + project: db::ProjectId,
297 + said: Option<&str>,
298 + ) -> Result<Response, RouteError> {
299 + let members = viewer
300 + .block_on(db::project_members::get_project_members(
301 + &viewer.app.db,
302 + project,
303 + ))
304 + .map_err(|_| RouteError::internal("the members could not be read"))?;
305 + let total = viewer
306 + .block_on(db::project_members::get_total_split_percent(
307 + &viewer.app.db,
308 + project,
309 + ))
310 + .map_err(|_| RouteError::internal("the splits could not be read"))?;
311 +
312 + let rows: Vec<ProjectMemberRow> = members
313 + .iter()
314 + .map(|m| ProjectMemberRow {
315 + id: m.id.to_string(),
316 + user_id: m.user_id.to_string(),
317 + username: m.username.clone(),
318 + display_name: m.display_name.clone(),
319 + role: m.role.to_string(),
320 + split_percent: m.split_percent,
321 + stripe_connected: m.stripe_account_id.is_some() && m.stripe_charges_enabled,
322 + accepted: m.is_accepted(),
323 + added_at: m.added_at.format("%Y-%m-%d").to_string(),
324 + })
325 + .collect();
326 +
327 + Ok(Response::fragment(
328 + REGION,
329 + pane(&rows, 100 - total, &project.to_string(), said),
330 + ))
331 + }
332 +
333 + /// Add a collaborator, and answer with the panel as it now stands.
334 + pub fn add(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
335 + let captures = request.captures;
336 + let payload = request.payload;
337 + let project = owned(viewer, &captures)?;
338 +
339 + let split: i16 = payload
340 + .get("split_percent")
341 + .and_then(|s| s.parse().ok())
342 + .ok_or_else(|| RouteError::conflict("that split is not a number"))?;
343 + if !(1..=99).contains(&split) {
344 + return Err(RouteError::conflict("Split must be between 1% and 99%"));
345 + }
346 +
347 + let username = payload
348 + .get("username")
349 + .ok_or_else(|| RouteError::conflict("a username is needed"))?;
350 + let username =
351 + db::Username::new(username).map_err(|_| RouteError::conflict("that is not a username"))?;
352 +
353 + let member = viewer
354 + .block_on(db::users::get_user_by_username(&viewer.app.db, &username))
355 + .map_err(|_| RouteError::internal("that user could not be read"))?
356 + .ok_or_else(|| RouteError::conflict("no user by that name"))?;
357 +
358 + if member.id == viewer.user.id {
359 + return Err(RouteError::conflict("You are already the project owner"));
360 + }
361 +
362 + let role = payload
363 + .get("role")
364 + .filter(|r| !r.is_empty())
365 + .and_then(|r| r.parse().ok())
366 + .unwrap_or(db::ProjectRole::Member);
367 +
368 + viewer
369 + .block_on(db::project_members::add_project_member(
370 + &viewer.app.db,
371 + project,
372 + member.id,
373 + role,
374 + split,
375 + viewer.user.id,
376 + ))
377 + .map_err(|_| RouteError::internal("that collaborator could not be added"))?;
378 +
379 + viewer
380 + .block_on(db::projects::bump_cache_generation(&viewer.app.db, project))
381 + .map_err(|_| RouteError::internal("the project could not be marked changed"))?;
382 +
383 + answer(viewer, project, Some(&invited(viewer, &member, split)))
384 + }
385 +
386 + /// What the owner is told, which depends on whose money crosses a currency.
387 + ///
388 + /// The sentence `add_project_member` answered with, kept rather than lost with
389 + /// the endpoint. See the module header.
390 + fn invited(viewer: &Viewer, member: &db::DbUser, split: i16) -> String {
391 + let owner_currency = viewer.user.settlement_currency;
392 + if member.settlement_currency == owner_currency {
393 + format!(
394 + "Invited @{} to a {split}% split. Their share starts when they accept.",
395 + member.username
396 + )
397 + } else {
398 + format!(
399 + "Invited @{} to a {split}% split. This project sells in {owner_currency}, but @{} is \
400 + paid in {}, so Stripe converts their share when it reaches them and the conversion \
401 + comes out of it. They will see that before they accept.",
402 + member.username, member.username, member.settlement_currency
403 + )
404 + }
405 + }
406 +
407 + /// Remove a collaborator, and answer with the panel as it now stands.
408 + pub fn remove(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
409 + let captures = request.captures;
410 + let project = owned(viewer, &captures)?;
411 +
412 + let user: db::UserId = captures
413 + .get("user")
414 + .and_then(|id| id.parse::<uuid::Uuid>().ok())
415 + .ok_or_else(|| RouteError::not_found("no such member"))?
416 + .into();
417 +
418 + let removed = viewer
419 + .block_on(db::project_members::remove_project_member(
420 + &viewer.app.db,
421 + project,
422 + user,
423 + ))
424 + .map_err(|_| RouteError::internal("that collaborator could not be removed"))?;
425 + if !removed {
426 + return Err(RouteError::not_found("no such member"));
427 + }
428 +
429 + viewer
430 + .block_on(db::projects::bump_cache_generation(&viewer.app.db, project))
431 + .map_err(|_| RouteError::internal("the project could not be marked changed"))?;
432 +
433 + answer(viewer, project, Some("Member removed"))
434 + }
435 +
436 + /// The renderer this panel's writes are drawn with.
437 + pub fn renderer(viewer: &Viewer) -> Webview {
438 + Webview::new().with_shell(viewer.shell())
439 + }
440 +
441 + #[cfg(test)]
442 + mod tests {
443 + use super::*;
444 +
445 + fn member(username: &str, accepted: bool) -> ProjectMemberRow {
446 + ProjectMemberRow {
447 + id: "m1".into(),
448 + user_id: "00000000-0000-0000-0000-000000000001".into(),
449 + username: username.into(),
450 + display_name: Some("Ada Lovelace".into()),
451 + role: "Producer".into(),
452 + split_percent: 30,
453 + stripe_connected: true,
454 + accepted,
455 + added_at: "2026-08-01".into(),
456 + }
457 + }
458 +
459 + fn render(members: &[ProjectMemberRow]) -> String {
460 + section(members, 70, "p1")
461 + }
462 +
463 + #[test]
464 + fn the_section_carries_the_region_its_writes_answer_into() {
465 + // Nothing else emits this id: the composite includes this section
466 + // rather than drawing a region around it. If the write answered a
467 + // different region it would land nowhere.
468 + let html = render(&[member("ada", true)]);
469 + assert!(html.contains(&format!("id=\"{REGION}\"")), "{html}");
470 + }
471 +
472 + #[test]
473 + fn both_controls_address_the_nest_and_not_the_api_route() {
474 + let html = render(&[member("ada", true)]);
475 +
476 + assert!(html.contains(&format!("hx-post=\"{NEST}/p1\"")), "{html}");
477 + assert!(
478 + html.contains(&format!(
479 + "hx-delete=\"{NEST}/p1/00000000-0000-0000-0000-000000000001\""
480 + )),
481 + "{html}"
482 + );
483 + // The API routes stay registered and are simply not what this panel
484 + // calls any more.
485 + assert!(!html.contains("/api/projects/"), "{html}");
486 + }
487 +
488 + #[test]
489 + fn nothing_here_goes_through_the_dispatcher() {
490 + // The whole point of `03c0977b`. These two sites were
491 + // `data-after="reset refresh"` and `data-after="refresh"`, each with
492 + // the panel's address and target passed positionally.
493 + let html = render(&[member("ada", false)]);
494 +
495 + assert!(!html.contains("data-after"), "{html}");
496 + assert!(!html.contains("data-arg"), "{html}");
497 + assert!(!html.contains("data-action"), "{html}");
498 + }
499 +
500 + #[test]
Lines truncated
@@ -1,105 +1,0 @@
1 - {%- import "partials/_ui.html" as ui -%}
2 - <div class="tab-docs"><a href="/docs/splits">Docs: Collaborators &rarr;</a></div>
3 -
4 - <div class="data-section">
5 - <h2 class="subsection-title">Members & Payouts</h2>
6 - <p class="section-lead">
7 - Add collaborators and set their share of revenue. The project owner receives the remainder.
8 - </p>
9 -
10 - <div class="proj-members-summary">
11 - <div class="proj-members-summary-row">
12 - <div>
13 - <div class="text-xs dimmed">Owner's share</div>
14 - <div class="proj-members-stat">{{ owner_split }}%</div>
15 - </div>
16 - <div>
17 - <div class="text-xs dimmed">Members</div>
18 - <div class="proj-members-stat">{{ members.len() }}</div>
19 - </div>
20 - </div>
21 - </div>
22 -
23 - <details class="form-section proj-members-add">
24 - <summary><h2 class="subsection-title">Add Member</h2></summary>
25 - <form hx-post="/api/projects/{{ project_id }}/members"
26 - hx-swap="none"
27 - data-after="reset refresh" data-arg="/dashboard/project/{{ project_slug }}/tabs/members" data-arg2="#project-monetization">
28 - <div class="proj-members-add-grid">
29 - <div>
30 - <label for="member-username" class="proj-members-field-label">Username</label>
31 - <input type="text" id="member-username" name="username" required
32 - placeholder="Enter username" class="proj-members-input-full w-full">
33 - </div>
34 - <div>
35 - <label for="member-split" class="proj-members-field-label">Split %</label>
36 - <input type="number" id="member-split" name="split_percent" required
37 - min="1" max="99" value="50" class="proj-members-input-split w-80">
38 - </div>
39 - <div>
40 - <button class="btn-primary" type="submit">Add</button>
41 - </div>
42 - </div>
43 - <div>
44 - <label for="member-role" class="proj-members-field-label mt-section">Role (optional)</label>
45 - <input type="text" id="member-role" name="role"
46 - placeholder="e.g. Producer, Artist, Engineer" class="proj-members-input-full w-full">
47 - </div>
48 - <div id="member-add-result" class="proj-members-add-result"></div>
49 - </form>
50 - </details>
51 -
52 - {% if members.is_empty() %}
53 - {% call ui::empty_state("", "No collaborators yet. Add team members above to share revenue automatically. The project owner receives 100% until splits are configured.") %}{% endcall %}
54 - {% else %}
55 - <table class="data-table well">
56 - <thead>
57 - <tr>
58 - <th>Member</th>
59 - <th>Role</th>
60 - <th>Split</th>
61 - <th>Stripe</th>
62 - <th>Added</th>
63 - <th></th>
64 - </tr>
65 - </thead>
66 - <tbody>
67 - {% for member in members %}
68 - <tr>
69 - <td>
70 - <a href="/u/{{ member.username }}" class="proj-members-name-link">
71 - <strong>{{ member.display_name.as_deref().unwrap_or(&member.username) }}</strong>
72 - </a>
73 - <div class="text-xs dimmed">@{{ member.username }}</div>
74 - </td>
75 - <td>{{ member.role }}</td>
76 - <td class="proj-members-split-cell">
77 - {{ member.split_percent }}%
78 - {% if !member.accepted %}
79 - <span class="badge" data-tone="warning">Invited</span>
80 - <div class="text-xs dimmed">
81 - Reserved, but earning nothing until they accept.
82 - </div>
83 - {% endif %}
84 - </td>
85 - <td>
86 - {% if member.stripe_connected %}
87 - <span class="badge" data-tone="success">Connected</span>
88 - {% else %}
89 - <span class="badge" data-tone="warning">Not connected</span>
90 - {% endif %}
91 - </td>
92 - <td class="text-xs">{{ member.added_at }}</td>
93 - <td>
94 - <button class="btn-danger proj-members-remove-btn"
95 - hx-delete="/api/projects/{{ project_id }}/members/{{ member.user_id }}"
96 - hx-swap="none"
97 - data-after="refresh" data-arg="/dashboard/project/{{ project_slug }}/tabs/members" data-arg2="#project-monetization"
98 - hx-confirm="Remove {{ member.display_name.as_deref().unwrap_or(&member.username) }} from this project?">Remove</button>
99 - </td>
100 - </tr>
101 - {% endfor %}
102 - </tbody>
103 - </table>
104 - {% endif %}
105 - </div>