max / makenotwork
6 files changed,
+129 insertions,
-11 deletions
| @@ -75,6 +75,7 @@ pub struct DbTagCount { | |||
| 75 | 75 | /// zero and would render a sidebar full of noise. | |
| 76 | 76 | #[derive(Debug, Clone, FromRow)] | |
| 77 | 77 | pub struct DbTagChild { | |
| 78 | + | pub tag_id: TagId, | |
| 78 | 79 | pub tag_name: String, | |
| 79 | 80 | pub tag_slug: String, | |
| 80 | 81 | pub count: i64, |
| @@ -281,6 +281,7 @@ pub async fn tag_children_with_counts( | |||
| 281 | 281 | let mut query = format!( | |
| 282 | 282 | r#" | |
| 283 | 283 | SELECT | |
| 284 | + | c.id AS tag_id, | |
| 284 | 285 | c.name AS tag_name, | |
| 285 | 286 | c.slug AS tag_slug, | |
| 286 | 287 | {count_subquery} AS count, |
| @@ -112,10 +112,28 @@ async fn build_sidebar( | |||
| 112 | 112 | let browse_cursor = query.browse.as_deref().filter(|s| !s.is_empty()); | |
| 113 | 113 | let drill_rows = | |
| 114 | 114 | db::tags::tag_children_with_counts(db, browse_cursor, &facet_filters).await?; | |
| 115 | + | ||
| 116 | + | // Follow state, for the rungs that can carry a follow control. Scoped to | |
| 117 | + | // the assignable rows rather than the viewer's whole followed set (fuzz | |
| 118 | + | // 2026-07-06 C5-1), and skipped entirely for anonymous viewers. | |
| 119 | + | let followed: std::collections::HashSet<db::TagId> = match viewer_id { | |
| 120 | + | Some(uid) => { | |
| 121 | + | let ids: Vec<_> = drill_rows | |
| 122 | + | .iter() | |
| 123 | + | .filter(|r| r.assignable) | |
| 124 | + | .map(|r| r.tag_id) | |
| 125 | + | .collect(); | |
| 126 | + | db::follows::following_subset(db, uid, &ids).await? | |
| 127 | + | } | |
| 128 | + | None => std::collections::HashSet::new(), | |
| 129 | + | }; | |
| 130 | + | ||
| 115 | 131 | tag_drill = drill_rows | |
| 116 | 132 | .into_iter() | |
| 117 | 133 | .map(|r| TagDrillRow { | |
| 118 | 134 | selected: tag_filter.iter().any(|t| t == &r.tag_slug), | |
| 135 | + | following: followed.contains(&r.tag_id), | |
| 136 | + | tag_id: r.tag_id.to_string(), | |
| 119 | 137 | slug: r.tag_slug, | |
| 120 | 138 | label: r.tag_name, | |
| 121 | 139 | count: r.count as u32, | |
| @@ -167,16 +185,6 @@ async fn build_sidebar( | |||
| 167 | 185 | } | |
| 168 | 186 | }) | |
| 169 | 187 | .collect(); | |
| 170 | - | // Only the first ~10 tag facets are rendered (see `.take(10)` below), so | |
| 171 | - | // test follow-membership against just those rather than fetching the | |
| 172 | - | // viewer's entire followed-tag set (fuzz 2026-07-06 C5-1). | |
| 173 | - | let followed_tag_ids = if let Some(uid) = viewer_id { | |
| 174 | - | let rendered_tag_ids: Vec<_> = | |
| 175 | - | tag_counts.iter().take(10).map(|tc| tc.tag_id).collect(); | |
| 176 | - | db::follows::following_subset(db, uid, &rendered_tag_ids).await? | |
| 177 | - | } else { | |
| 178 | - | std::collections::HashSet::new() | |
| 179 | - | }; | |
| 180 | 188 | ||
| 181 | 189 | let mut type_filters: Vec<FilterCategory> = vec![FilterCategory { | |
| 182 | 190 | name: "All".to_string(), | |
| @@ -213,7 +221,7 @@ async fn build_sidebar( | |||
| 213 | 221 | count: tc.count as u32, | |
| 214 | 222 | active: tag_filter.iter().any(|t| t == &tc.tag_slug), | |
| 215 | 223 | id: tc.tag_id.to_string(), | |
| 216 | - | following: followed_tag_ids.contains(&tc.tag_id), | |
| 224 | + | following: false, | |
| 217 | 225 | }); | |
| 218 | 226 | } | |
| 219 | 227 | ||
| @@ -347,6 +355,7 @@ async fn build_sidebar( | |||
| 347 | 355 | browse_url_root, | |
| 348 | 356 | has_source: has_source_code, | |
| 349 | 357 | active_filter_count, | |
| 358 | + | viewer_authenticated: viewer_id.is_some(), | |
| 350 | 359 | }) | |
| 351 | 360 | } | |
| 352 | 361 |
| @@ -75,6 +75,8 @@ pub struct TagChip { | |||
| 75 | 75 | ||
| 76 | 76 | /// One rung of the tag drill-down. | |
| 77 | 77 | pub struct TagDrillRow { | |
| 78 | + | /// Needed to address the follow endpoint, which is keyed on tag id. | |
| 79 | + | pub tag_id: String, | |
| 78 | 80 | pub slug: String, | |
| 79 | 81 | pub label: String, | |
| 80 | 82 | pub count: u32, | |
| @@ -83,6 +85,12 @@ pub struct TagDrillRow { | |||
| 83 | 85 | pub has_children: bool, | |
| 84 | 86 | /// Already in the active selection, so the control renders as selected. | |
| 85 | 87 | pub selected: bool, | |
| 88 | + | /// The viewer already follows this tag. | |
| 89 | + | /// | |
| 90 | + | /// Only ever true for assignable rows: following is exact-match, so a | |
| 91 | + | /// follow on a category would match nothing (items carry depth-3+ leaves | |
| 92 | + | /// only). The control is offered on leaves for the same reason. | |
| 93 | + | pub following: bool, | |
| 86 | 94 | } | |
| 87 | 95 | ||
| 88 | 96 | /// A rung of the drill-down's breadcrumb trail, for navigating back up. | |
| @@ -141,4 +149,6 @@ pub struct SidebarView { | |||
| 141 | 149 | pub browse_url_root: String, | |
| 142 | 150 | pub has_source: bool, | |
| 143 | 151 | pub active_filter_count: u32, | |
| 152 | + | /// Follow controls are only meaningful for a signed-in viewer. | |
| 153 | + | pub viewer_authenticated: bool, | |
| 144 | 154 | } |
| @@ -117,6 +117,26 @@ | |||
| 117 | 117 | <span>{{ row.label }}</span> | |
| 118 | 118 | <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span> | |
| 119 | 119 | </label> | |
| 120 | + | {# Follow is a SIBLING of the label, never inside it: | |
| 121 | + | a control nested in a label would toggle the | |
| 122 | + | checkbox when clicked. Offered only on assignable | |
| 123 | + | leaves, because following is exact-match and a | |
| 124 | + | follow on a category would match nothing. #} | |
| 125 | + | {% if sidebar.viewer_authenticated %} | |
| 126 | + | {% if row.following %} | |
| 127 | + | <button class="tag-follow-btn is-selected" | |
| 128 | + | hx-delete="/api/follow/tag/{{ row.tag_id }}" | |
| 129 | + | hx-swap="outerHTML" | |
| 130 | + | aria-label="Unfollow {{ row.label }}" | |
| 131 | + | data-action="noop" data-stop>Following</button> | |
| 132 | + | {% else %} | |
| 133 | + | <button class="tag-follow-btn" | |
| 134 | + | hx-post="/api/follow/tag/{{ row.tag_id }}" | |
| 135 | + | hx-swap="outerHTML" | |
| 136 | + | aria-label="Follow {{ row.label }}" | |
| 137 | + | data-action="noop" data-stop>Follow</button> | |
| 138 | + | {% endif %} | |
| 139 | + | {% endif %} | |
| 120 | 140 | {% endif %} | |
| 121 | 141 | {% if row.has_children %} | |
| 122 | 142 | <a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}" |
| @@ -442,6 +442,83 @@ async fn price_filter_round_trips_into_its_inputs() { | |||
| 442 | 442 | } | |
| 443 | 443 | ||
| 444 | 444 | // --------------------------------------------------------------------------- | |
| 445 | + | // Tag following. | |
| 446 | + | // --------------------------------------------------------------------------- | |
| 447 | + | ||
| 448 | + | /// The drill-down offers a follow control, and only where following works. | |
| 449 | + | /// | |
| 450 | + | /// Following is exact-match: `follows.target_id` joins `item_tags.tag_id` | |
| 451 | + | /// directly, with no descendant expansion. Items carry depth-3+ leaves only, so | |
| 452 | + | /// a follow on a category would match nothing forever. The control is therefore | |
| 453 | + | /// offered on assignable rungs and withheld on navigational ones, even though | |
| 454 | + | /// the tag filter beside it does expand to descendants. | |
| 455 | + | #[tokio::test] | |
| 456 | + | async fn follow_control_appears_only_on_assignable_rungs() { | |
| 457 | + | let mut h = TestHarness::new().await; | |
| 458 | + | h.signup("followui", "followui@example.com", "password123").await; | |
| 459 | + | ||
| 460 | + | // Root: every rung is a depth-1 type, none assignable, so no follow control. | |
| 461 | + | let root = h.client.get("/discover?mode=items").await; | |
| 462 | + | assert!(root.status.is_success(), "{}", root.status); | |
| 463 | + | assert!( | |
| 464 | + | !root.text.contains("tag-follow-btn"), | |
| 465 | + | "categories cannot be followed meaningfully, so offer no control" | |
| 466 | + | ); | |
| 467 | + | ||
| 468 | + | // Drilled to leaves: the control appears. | |
| 469 | + | let leaves = h.client.get("/discover?mode=items&browse=audio.genre").await; | |
| 470 | + | assert!(leaves.status.is_success(), "{}", leaves.status); | |
| 471 | + | assert!( | |
| 472 | + | leaves.text.contains("tag-follow-btn"), | |
| 473 | + | "assignable leaves should offer a follow control" | |
| 474 | + | ); | |
| 475 | + | assert!( | |
| 476 | + | leaves.text.contains("/api/follow/tag/"), | |
| 477 | + | "the control must address the tag follow endpoint" | |
| 478 | + | ); | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | /// Anonymous visitors get no follow control, and cost no follow query. | |
| 482 | + | #[tokio::test] | |
| 483 | + | async fn follow_control_is_hidden_from_anonymous_visitors() { | |
| 484 | + | let mut h = TestHarness::new().await; | |
| 485 | + | let resp = h.client.get("/discover?mode=items&browse=audio.genre").await; | |
| 486 | + | assert!(resp.status.is_success(), "{}", resp.status); | |
| 487 | + | assert!( | |
| 488 | + | !resp.text.contains("tag-follow-btn"), | |
| 489 | + | "a signed-out visitor has nothing to follow with" | |
| 490 | + | ); | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | /// Following a tag round-trips into the rendered control. | |
| 494 | + | #[tokio::test] | |
| 495 | + | async fn following_a_tag_is_reflected_in_the_sidebar() { | |
| 496 | + | let mut h = TestHarness::new().await; | |
| 497 | + | h.signup("followrt", "followrt@example.com", "password123").await; | |
| 498 | + | ||
| 499 | + | let tag_id: uuid::Uuid = | |
| 500 | + | sqlx::query_scalar("SELECT id FROM tags WHERE slug = 'audio.genre.electronic'") | |
| 501 | + | .fetch_one(&h.db) | |
| 502 | + | .await | |
| 503 | + | .expect("seeded leaf tag exists"); | |
| 504 | + | ||
| 505 | + | let before = h.client.get("/discover?mode=items&browse=audio.genre").await; | |
| 506 | + | assert!(before.text.contains(">Follow<"), "should start unfollowed"); | |
| 507 | + | ||
| 508 | + | let resp = h | |
| 509 | + | .client | |
| 510 | + | .post_form(&format!("/api/follow/tag/{tag_id}"), "") | |
| 511 | + | .await; | |
| 512 | + | assert!(resp.status.is_success(), "follow failed: {}", resp.status); | |
| 513 | + | ||
| 514 | + | let after = h.client.get("/discover?mode=items&browse=audio.genre").await; | |
| 515 | + | assert!( | |
| 516 | + | after.text.contains(">Following<"), | |
| 517 | + | "the sidebar must reflect the follow it just recorded" | |
| 518 | + | ); | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | // --------------------------------------------------------------------------- | |
| 445 | 522 | // Markup/stylesheet contract. | |
| 446 | 523 | // --------------------------------------------------------------------------- | |
| 447 | 524 |