max / makenotwork
8 files changed,
+723 insertions,
-401 deletions
| @@ -23,6 +23,354 @@ use crate::{ | |||
| 23 | 23 | types::*, | |
| 24 | 24 | }; | |
| 25 | 25 | ||
| 26 | + | /// Build the sidebar view model. | |
| 27 | + | /// | |
| 28 | + | /// Shared by the full page and the `/discover/results` partial. The partial | |
| 29 | + | /// needs it because the sidebar is swapped out-of-band on every filter change: | |
| 30 | + | /// its counts describe the current filter, so leaving them behind would show | |
| 31 | + | /// numbers that no longer match the results beside them. | |
| 32 | + | async fn build_sidebar( | |
| 33 | + | db: &PgPool, | |
| 34 | + | query: &DiscoverQuery, | |
| 35 | + | data: &DiscoverData, | |
| 36 | + | viewer_id: Option<db::UserId>, | |
| 37 | + | ) -> Result<SidebarView> { | |
| 38 | + | let f = query.filter_selection(); | |
| 39 | + | let search_filter = f.search; | |
| 40 | + | let tag_filter = f.tags; | |
| 41 | + | let item_type_filter = f.item_types; | |
| 42 | + | let has_source_code = f.has_source_code; | |
| 43 | + | ||
| 44 | + | // Build type and tag filters (items mode only) | |
| 45 | + | let category_filter = f.category; | |
| 46 | + | ||
| 47 | + | // Build category filters (projects mode only) | |
| 48 | + | let category_filters = if data.mode == "projects" { | |
| 49 | + | let cat_counts = db::categories::get_category_counts( | |
| 50 | + | db, | |
| 51 | + | search_filter, | |
| 52 | + | ) | |
| 53 | + | .await?; | |
| 54 | + | ||
| 55 | + | let mut filters: Vec<FilterCategory> = vec![FilterCategory { | |
| 56 | + | name: "All".to_string(), | |
| 57 | + | value: String::new(), | |
| 58 | + | count: data.total_count, | |
| 59 | + | active: category_filter.is_none(), | |
| 60 | + | id: String::new(), | |
| 61 | + | following: false, | |
| 62 | + | }]; | |
| 63 | + | for cc in cat_counts { | |
| 64 | + | filters.push(FilterCategory { | |
| 65 | + | name: cc.name, | |
| 66 | + | value: cc.slug.to_string(), | |
| 67 | + | count: cc.count as u32, | |
| 68 | + | active: category_filter == Some(cc.slug.as_str()), | |
| 69 | + | id: String::new(), | |
| 70 | + | following: false, | |
| 71 | + | }); | |
| 72 | + | } | |
| 73 | + | filters | |
| 74 | + | } else { | |
| 75 | + | vec![] | |
| 76 | + | }; | |
| 77 | + | ||
| 78 | + | // The applied bounds, rendered back into the number inputs. Without this the | |
| 79 | + | // inputs come back blank, hx-include resends them empty, and the price filter | |
| 80 | + | // silently disappears on the next interaction. | |
| 81 | + | let (shown_min, shown_max) = sanitize_price_range(query.min_price, query.max_price); | |
| 82 | + | let current_min_price = shown_min.map(|v| v.to_string()).unwrap_or_default(); | |
| 83 | + | let current_max_price = shown_max.map(|v| v.to_string()).unwrap_or_default(); | |
| 84 | + | ||
| 85 | + | // Built before the template literal partially moves `query`. | |
| 86 | + | let browse_url_prefix = query.browse_base_url(); | |
| 87 | + | let browse_url_root = query.browse_root_url(); | |
| 88 | + | ||
| 89 | + | let mut tag_chips: Vec<TagChip> = Vec::new(); | |
| 90 | + | let mut tag_drill: Vec<TagDrillRow> = Vec::new(); | |
| 91 | + | let mut tag_crumbs: Vec<TagCrumb> = Vec::new(); | |
| 92 | + | ||
| 93 | + | let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" { | |
| 94 | + | // The four facet counts are viewer-independent -> served from a short-TTL | |
| 95 | + | // memo (ultra-fuzz Run 12 Performance). Only `followed_tag_ids` is | |
| 96 | + | // per-viewer, so it is computed fresh (and only when logged in). | |
| 97 | + | let facet_filters = DiscoverFilters { | |
| 98 | + | search: search_filter, | |
| 99 | + | item_types: &item_type_filter, | |
| 100 | + | tags: &tag_filter, | |
| 101 | + | min_price: query.min_price, | |
| 102 | + | max_price: query.max_price, | |
| 103 | + | sort_by: None, | |
| 104 | + | ai_tier: f.ai_tier, | |
| 105 | + | }; | |
| 106 | + | let (type_counts, tag_counts, ai_counts, price_counts) = | |
| 107 | + | cached_facets(db, &facet_filters).await?; | |
| 108 | + | ||
| 109 | + | // The drill-down cursor. Not cached alongside the facets: it varies by | |
| 110 | + | // an axis the facet key does not carry, and it is one cheap indexed | |
| 111 | + | // query over a ~120-row table. | |
| 112 | + | let browse_cursor = query.browse.as_deref().filter(|s| !s.is_empty()); | |
| 113 | + | let drill_rows = | |
| 114 | + | db::tags::tag_children_with_counts(db, browse_cursor, &facet_filters).await?; | |
| 115 | + | tag_drill = drill_rows | |
| 116 | + | .into_iter() | |
| 117 | + | .map(|r| TagDrillRow { | |
| 118 | + | selected: tag_filter.iter().any(|t| t == &r.tag_slug), | |
| 119 | + | slug: r.tag_slug, | |
| 120 | + | label: r.tag_name, | |
| 121 | + | count: r.count as u32, | |
| 122 | + | assignable: r.assignable, | |
| 123 | + | has_children: r.has_children, | |
| 124 | + | }) | |
| 125 | + | .collect(); | |
| 126 | + | ||
| 127 | + | // Breadcrumbs are derived from the cursor's own dot-path rather than | |
| 128 | + | // queried: tagtree::ancestors is exactly this, and every ancestor of a | |
| 129 | + | // valid slug is itself a valid slug. | |
| 130 | + | tag_crumbs = browse_cursor | |
| 131 | + | .map(|cursor| { | |
| 132 | + | tagtree::ancestors(cursor) | |
| 133 | + | .into_iter() | |
| 134 | + | .chain(std::iter::once(cursor)) | |
| 135 | + | .map(|slug| TagCrumb { | |
| 136 | + | slug: slug.to_string(), | |
| 137 | + | label: tagtree::leaf(slug).replace('-', " "), | |
| 138 | + | }) | |
| 139 | + | .collect() | |
| 140 | + | }) | |
| 141 | + | .unwrap_or_default(); | |
| 142 | + | ||
| 143 | + | // Chips carry a precomputed dismiss query so the template stays free of | |
| 144 | + | // list manipulation, and so removal is a plain link that works without JS. | |
| 145 | + | let chip_names: std::collections::HashMap<String, String> = | |
| 146 | + | db::tags::tag_names_for_slugs(db, &tag_filter) | |
| 147 | + | .await? | |
| 148 | + | .into_iter() | |
| 149 | + | .collect(); | |
| 150 | + | tag_chips = tag_filter | |
| 151 | + | .iter() | |
| 152 | + | .map(|slug| { | |
| 153 | + | let remove_query = tag_filter | |
| 154 | + | .iter() | |
| 155 | + | .filter(|other| *other != slug) | |
| 156 | + | .map(|other| format!("tag={}", urlencoding::encode(other))) | |
| 157 | + | .collect::<Vec<_>>() | |
| 158 | + | .join("&"); | |
| 159 | + | TagChip { | |
| 160 | + | label: chip_names | |
| 161 | + | .get(slug) | |
| 162 | + | .cloned() | |
| 163 | + | .unwrap_or_else(|| tagtree::leaf(slug).replace('-', " ")), | |
| 164 | + | context: tagtree::parent(slug).unwrap_or("").to_string(), | |
| 165 | + | slug: slug.clone(), | |
| 166 | + | remove_query, | |
| 167 | + | } | |
| 168 | + | }) | |
| 169 | + | .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 | + | ||
| 181 | + | let mut type_filters: Vec<FilterCategory> = vec![FilterCategory { | |
| 182 | + | name: "All".to_string(), | |
| 183 | + | value: String::new(), | |
| 184 | + | count: data.total_count, | |
| 185 | + | active: item_type_filter.is_empty(), | |
| 186 | + | id: String::new(), | |
| 187 | + | following: false, | |
| 188 | + | }]; | |
| 189 | + | for tc in type_counts { | |
| 190 | + | let active = item_type_filter.iter().any(|t| t.to_string() == tc.category); | |
| 191 | + | type_filters.push(FilterCategory { | |
| 192 | + | value: tc.category.clone(), | |
| 193 | + | name: tc.category, | |
| 194 | + | count: tc.count as u32, | |
| 195 | + | active, | |
| 196 | + | id: String::new(), | |
| 197 | + | following: false, | |
| 198 | + | }); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | let mut tag_filters: Vec<FilterCategory> = vec![FilterCategory { | |
| 202 | + | name: "All".to_string(), | |
| 203 | + | value: String::new(), | |
| 204 | + | count: data.total_count, | |
| 205 | + | active: tag_filter.is_empty(), | |
| 206 | + | id: String::new(), | |
| 207 | + | following: false, | |
| 208 | + | }]; | |
| 209 | + | for tc in tag_counts.iter().take(10) { | |
| 210 | + | tag_filters.push(FilterCategory { | |
| 211 | + | name: tc.tag_name.clone(), | |
| 212 | + | value: tc.tag_slug.to_string(), | |
| 213 | + | count: tc.count as u32, | |
| 214 | + | active: tag_filter.iter().any(|t| t == &tc.tag_slug), | |
| 215 | + | id: tc.tag_id.to_string(), | |
| 216 | + | following: followed_tag_ids.contains(&tc.tag_id), | |
| 217 | + | }); | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // Per `about/generative-ai.md` § "How Fans Use This", the three | |
| 221 | + | // filter options are "Everything" / "Human-led" (Handmade ∪ | |
| 222 | + | // Assisted) / "Handmade only". Aggregate the per-tier counts | |
| 223 | + | // into option-sized counts before passing to the template. | |
| 224 | + | let mut handmade_count: u32 = 0; | |
| 225 | + | let mut assisted_count: u32 = 0; | |
| 226 | + | for ac in &ai_counts { | |
| 227 | + | match ac.category.as_str() { | |
| 228 | + | "handmade" => handmade_count = ac.count as u32, | |
| 229 | + | "assisted" => assisted_count = ac.count as u32, | |
| 230 | + | _ => {} | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | let ai_tier_filter_str = query.ai_tier.as_deref().filter(|s| !s.is_empty()); | |
| 234 | + | let ai_tier_filters: Vec<FilterCategory> = vec![ | |
| 235 | + | FilterCategory { | |
| 236 | + | name: "Everything".to_string(), | |
| 237 | + | value: String::new(), | |
| 238 | + | count: data.total_count, | |
| 239 | + | active: ai_tier_filter_str.is_none(), | |
| 240 | + | id: String::new(), | |
| 241 | + | following: false, | |
| 242 | + | }, | |
| 243 | + | FilterCategory { | |
| 244 | + | name: db::AiTierFilter::HumanLed.label().to_string(), | |
| 245 | + | value: db::AiTierFilter::HumanLed.to_string(), | |
| 246 | + | count: handmade_count + assisted_count, | |
| 247 | + | active: ai_tier_filter_str == Some(db::AiTierFilter::HumanLed.to_string().as_str()), | |
| 248 | + | id: String::new(), | |
| 249 | + | following: false, | |
| 250 | + | }, | |
| 251 | + | FilterCategory { | |
| 252 | + | name: db::AiTierFilter::HandmadeOnly.label().to_string(), | |
| 253 | + | value: db::AiTierFilter::HandmadeOnly.to_string(), | |
| 254 | + | count: handmade_count, | |
| 255 | + | active: ai_tier_filter_str == Some(db::AiTierFilter::HandmadeOnly.to_string().as_str()), | |
| 256 | + | id: String::new(), | |
| 257 | + | following: false, | |
| 258 | + | }, | |
| 259 | + | ]; | |
| 260 | + | ||
| 261 | + | (type_filters, tag_filters, ai_tier_filters, price_counts) | |
| 262 | + | } else { | |
| 263 | + | (vec![], vec![], vec![], db::DbPriceRangeCounts::default()) | |
| 264 | + | }; | |
| 265 | + | ||
| 266 | + | let price_filters = vec![ | |
| 267 | + | PriceFilter { label: "Free".to_string(), count: price_counts.free as u32 }, | |
| 268 | + | PriceFilter { label: "Under $25".to_string(), count: price_counts.under_25 as u32 }, | |
| 269 | + | PriceFilter { label: "$25-50".to_string(), count: price_counts.range_25_50 as u32 }, | |
| 270 | + | PriceFilter { label: "$50-100".to_string(), count: price_counts.range_50_100 as u32 }, | |
| 271 | + | PriceFilter { label: "$100+".to_string(), count: price_counts.over_100 as u32 }, | |
| 272 | + | ]; | |
| 273 | + | ||
| 274 | + | // The same five buckets as clickable ranges. Bounds mirror the FILTER | |
| 275 | + | // clauses in db::discover::get_price_range_counts exactly; if one moves the | |
| 276 | + | // other must, or a bucket will report a count its own link cannot reproduce. | |
| 277 | + | let price_base_params = query.params_without_price(); | |
| 278 | + | let (applied_min, applied_max) = sanitize_price_range(query.min_price, query.max_price); | |
| 279 | + | let price_buckets: Vec<PriceBucket> = [ | |
| 280 | + | ("Free", price_counts.free, Some(0), Some(0)), | |
| 281 | + | ("Under $25", price_counts.under_25, Some(1), Some(2499)), | |
| 282 | + | ("$25-50", price_counts.range_25_50, Some(2500), Some(4999)), | |
| 283 | + | ("$50-100", price_counts.range_50_100, Some(5000), Some(9999)), | |
| 284 | + | ("$100+", price_counts.over_100, Some(10000), None), | |
| 285 | + | ] | |
| 286 | + | .into_iter() | |
| 287 | + | .map(|(label, count, min, max)| { | |
| 288 | + | let mut parts = price_base_params.clone(); | |
| 289 | + | if let Some(v) = min { | |
| 290 | + | parts.push(format!("min_price={v}")); | |
| 291 | + | } | |
| 292 | + | if let Some(v) = max { | |
| 293 | + | parts.push(format!("max_price={v}")); | |
| 294 | + | } | |
| 295 | + | PriceBucket { | |
| 296 | + | label: label.to_string(), | |
| 297 | + | count: count as u32, | |
| 298 | + | url: if parts.is_empty() { | |
| 299 | + | "/discover".to_string() | |
| 300 | + | } else { | |
| 301 | + | format!("/discover?{}", parts.join("&")) | |
| 302 | + | }, | |
| 303 | + | active: applied_min == min && applied_max == max, | |
| 304 | + | } | |
| 305 | + | }) | |
| 306 | + | .collect(); | |
| 307 | + | ||
| 308 | + | // The mobile filter badge counts *groups* with an active selection, not | |
| 309 | + | // individual values: picking three tags is still one filter in use. | |
| 310 | + | let current_types: Vec<String> = | |
| 311 | + | dedup_nonempty(&query.item_type).into_iter().map(str::to_string).collect(); | |
| 312 | + | let current_tags: Vec<String> = | |
| 313 | + | dedup_nonempty(&query.tag).into_iter().map(str::to_string).collect(); | |
| 314 | + | let current_category = query.category.clone().unwrap_or_default(); | |
| 315 | + | let current_ai_tier = query.ai_tier.clone().unwrap_or_default(); | |
| 316 | + | ||
| 317 | + | // A selection is only carried by a hidden input when no visible control | |
| 318 | + | // represents it. The drill-down shows one rung at a time, so a tag chosen | |
| 319 | + | // from elsewhere in the tree has no checkbox on screen; likewise a type | |
| 320 | + | // whose count dropped out of the facet list. Rendering a hidden input for a | |
| 321 | + | // value that also has a checked checkbox would submit it twice. | |
| 322 | + | let visible_tag_slugs: std::collections::HashSet<&str> = | |
| 323 | + | tag_drill.iter().map(|r| r.slug.as_str()).collect(); | |
| 324 | + | let hidden_tags: Vec<String> = current_tags | |
| 325 | + | .iter() | |
| 326 | + | .filter(|t| !visible_tag_slugs.contains(t.as_str())) | |
| 327 | + | .cloned() | |
| 328 | + | .collect(); | |
| 329 | + | let visible_type_values: std::collections::HashSet<&str> = | |
| 330 | + | type_filters.iter().map(|t| t.value.as_str()).collect(); | |
| 331 | + | let hidden_types: Vec<String> = current_types | |
| 332 | + | .iter() | |
| 333 | + | .filter(|t| !visible_type_values.contains(t.as_str())) | |
| 334 | + | .cloned() | |
| 335 | + | .collect(); | |
| 336 | + | ||
| 337 | + | ||
| 338 | + | let active_filter_count = [ | |
| 339 | + | !current_types.is_empty(), | |
| 340 | + | !current_tags.is_empty(), | |
| 341 | + | !current_category.is_empty(), | |
| 342 | + | !current_ai_tier.is_empty(), | |
| 343 | + | has_source_code, | |
| 344 | + | query.min_price.is_some(), | |
| 345 | + | query.max_price.is_some(), | |
| 346 | + | ].iter().filter(|&&v| v).count() as u32; | |
| 347 | + | ||
| 348 | + | ||
| 349 | + | Ok(SidebarView { | |
| 350 | + | type_filters, | |
| 351 | + | tag_filters, | |
| 352 | + | category_filters, | |
| 353 | + | price_filters, | |
| 354 | + | price_buckets, | |
| 355 | + | ai_tier_filters, | |
| 356 | + | tag_chips, | |
| 357 | + | tag_drill, | |
| 358 | + | tag_crumbs, | |
| 359 | + | hidden_tags, | |
| 360 | + | hidden_types, | |
| 361 | + | current_types, | |
| 362 | + | current_tags, | |
| 363 | + | current_min_price, | |
| 364 | + | current_max_price, | |
| 365 | + | current_category, | |
| 366 | + | current_ai_tier, | |
| 367 | + | browse_url_prefix, | |
| 368 | + | browse_url_root, | |
| 369 | + | has_source: has_source_code, | |
| 370 | + | active_filter_count, | |
| 371 | + | }) | |
| 372 | + | } | |
| 373 | + | ||
| 26 | 374 | /// The four viewer-independent discover facet results (item-type, tag, ai-tier, and | |
| 27 | 375 | /// price-range counts). `followed_tag_ids` is deliberately excluded — it is | |
| 28 | 376 | /// per-viewer and always computed fresh. | |
| @@ -40,7 +388,13 @@ type FacetBundle = ( | |||
| 40 | 388 | /// AI-tier filter, which made it harmless; the moment the facets started | |
| 41 | 389 | /// cross-applying it, leaving it out would have served one tier's counts to | |
| 42 | 390 | /// another for up to `FACET_CACHE_TTL`. | |
| 391 | + | /// The leading `String` is the database name. The memo is a process-global, so | |
| 392 | + | /// without it two pools pointed at different databases would share counts. In | |
| 393 | + | /// production there is one database and this is a constant; under test every | |
| 394 | + | /// case gets its own template clone, and omitting it silently served one test's | |
| 395 | + | /// facet counts to another. | |
| 43 | 396 | type FacetKey = ( | |
| 397 | + | String, | |
| 44 | 398 | Option<String>, | |
| 45 | 399 | Vec<ItemType>, | |
| 46 | 400 | Vec<String>, | |
| @@ -67,6 +421,7 @@ static FACET_CACHE: OnceLock<Mutex<HashMap<FacetKey, (Instant, FacetBundle)>>> = | |||
| 67 | 421 | /// instead of five full-catalog aggregate scans holding five pool connections. | |
| 68 | 422 | async fn cached_facets(db: &PgPool, filters: &db::discover::DiscoverFilters<'_>) -> Result<FacetBundle> { | |
| 69 | 423 | let key: FacetKey = ( | |
| 424 | + | db.connect_options().get_database().unwrap_or_default().to_string(), | |
| 70 | 425 | filters.search.map(str::to_string), | |
| 71 | 426 | filters.item_types.to_vec(), | |
| 72 | 427 | filters.tags.to_vec(), | |
| @@ -510,528 +865,218 @@ impl DiscoverQuery { | |||
| 510 | 865 | item_types: dedup_nonempty(&self.item_type) | |
| 511 | 866 | .into_iter() | |
| 512 | 867 | .filter_map(|s| s.parse().ok()) | |
| 513 | - | .collect(), | |
| 514 | - | tags: dedup_nonempty(&self.tag) | |
| 515 | - | .into_iter() | |
| 516 | - | .map(str::to_string) | |
| 517 | - | .collect(), | |
| 518 | - | search: self.q.as_deref().filter(|s| !s.trim().is_empty()), | |
| 519 | - | category: self.category.as_deref().filter(|s| !s.is_empty()), | |
| 520 | - | ai_tier: self.ai_tier.as_deref().filter(|s| !s.is_empty()).and_then(|s| s.parse().ok()), | |
| 521 | - | has_source_code: self.has_source.as_deref() == Some("1"), | |
| 522 | - | } | |
| 523 | - | } | |
| 524 | - | } | |
| 525 | - | ||
| 526 | - | /// Drop blank entries and duplicates while preserving order. | |
| 527 | - | /// | |
| 528 | - | /// Duplicates are dropped so a doubled `?tag=x&tag=x` cannot inflate the bind | |
| 529 | - | /// arrays; order is preserved so the pushed URL is stable across a round-trip | |
| 530 | - | /// and doesn't churn browser history. | |
| 531 | - | fn dedup_nonempty(values: &[String]) -> Vec<&str> { | |
| 532 | - | let mut seen = std::collections::HashSet::new(); | |
| 533 | - | values | |
| 534 | - | .iter() | |
| 535 | - | .map(|s| s.trim()) | |
| 536 | - | .filter(|s| !s.is_empty()) | |
| 537 | - | .filter(|s| seen.insert(*s)) | |
| 538 | - | .collect() | |
| 539 | - | } | |
| 540 | - | ||
| 541 | - | async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> { | |
| 542 | - | // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a | |
| 543 | - | // giant OFFSET = one expensive deep scan per request. Matches the git/admin | |
| 544 | - | // list handlers. | |
| 545 | - | let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000); | |
| 546 | - | let limit = constants::DISCOVER_PAGE_SIZE as i64; | |
| 547 | - | let offset = ((page - 1) as i64) * limit; | |
| 548 | - | let mode = query.mode.as_deref().unwrap_or("projects"); | |
| 549 | - | ||
| 550 | - | let f = query.filter_selection(); | |
| 551 | - | let item_type_filter = f.item_types; | |
| 552 | - | let tag_filter = f.tags; | |
| 553 | - | let search_filter = f.search; | |
| 554 | - | let category_filter = f.category; | |
| 555 | - | let ai_tier_filter = f.ai_tier; | |
| 556 | - | let has_source_code = f.has_source_code; | |
| 557 | - | ||
| 558 | - | let (items, projects, total_count) = if mode == "projects" { | |
| 559 | - | let sort_filter: Option<DiscoverSort> = query.sort.as_deref() | |
| 560 | - | .filter(|s| !s.is_empty()) | |
| 561 | - | .and_then(|s| s.parse().ok()); | |
| 562 | - | ||
| 563 | - | let db_projects = db::discover::discover_projects( | |
| 564 | - | pool, | |
| 565 | - | search_filter, | |
| 566 | - | category_filter, | |
| 567 | - | sort_filter, | |
| 568 | - | has_source_code, | |
| 569 | - | limit, | |
| 570 | - | offset, | |
| 571 | - | ) | |
| 572 | - | .await?; | |
| 573 | - | ||
| 574 | - | let total = db::discover::count_discover_projects( | |
| 575 | - | pool, | |
| 576 | - | search_filter, | |
| 577 | - | category_filter, | |
| 578 | - | has_source_code, | |
| 579 | - | ) | |
| 580 | - | .await?; | |
| 581 | - | ||
| 582 | - | let projects: Vec<DiscoverProject> = db_projects.into_iter().map(DiscoverProject::from).collect(); | |
| 583 | - | (vec![], projects, total as u32) | |
| 584 | - | } else { | |
| 585 | - | let sort_filter: Option<DiscoverSort> = query.sort.as_deref() | |
| 586 | - | .filter(|s| !s.is_empty()) | |
| 587 | - | .and_then(|s| s.parse().ok()); | |
| 588 | - | ||
| 589 | - | let (min_price, max_price) = sanitize_price_range(query.min_price, query.max_price); | |
| 590 | - | let filters = DiscoverFilters { | |
| 591 | - | search: search_filter, | |
| 592 | - | item_types: &item_type_filter, | |
| 593 | - | tags: &tag_filter, | |
| 594 | - | min_price, | |
| 595 | - | max_price, | |
| 596 | - | sort_by: sort_filter, | |
| 597 | - | ai_tier: ai_tier_filter, | |
| 598 | - | }; | |
| 599 | - | ||
| 600 | - | let db_items = db::discover::discover_items(pool, &filters, limit, offset).await?; | |
| 601 | - | let total = db::discover::count_discover_items(pool, &filters).await?; | |
| 602 | - | ||
| 603 | - | let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect(); | |
| 604 | - | (items, vec![], total as u32) | |
| 605 | - | }; | |
| 606 | - | ||
| 607 | - | let total_pages = ((total_count as f64) / (limit as f64)).ceil() as u32; | |
| 608 | - | let pagination_range = super::pagination::build_pagination_range(page, total_pages); | |
| 609 | - | let result_count = if mode == "projects" { | |
| 610 | - | projects.len() as u32 | |
| 611 | - | } else { | |
| 612 | - | items.len() as u32 | |
| 613 | - | }; | |
| 614 | - | // Reuse the i64 `offset` (computed overflow-safe above) for the "showing | |
| 615 | - | // X–Y" labels and saturate into u32, rather than recomputing | |
| 616 | - | // `(page - 1) * DISCOVER_PAGE_SIZE` in u32 — which overflows for a large `?page=`. | |
| 617 | - | let showing_start = if result_count == 0 { | |
| 618 | - | 0 | |
| 619 | - | } else { | |
| 620 | - | offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32 | |
| 621 | - | }; | |
| 622 | - | let showing_end = offset | |
| 623 | - | .saturating_add(result_count as i64) | |
| 624 | - | .clamp(0, u32::MAX as i64) as u32; | |
| 625 | - | ||
| 626 | - | Ok(DiscoverData { | |
| 627 | - | items, | |
| 628 | - | projects, | |
| 629 | - | mode: mode.to_string(), | |
| 630 | - | total_count, | |
| 631 | - | current_page: page, | |
| 632 | - | total_pages, | |
| 633 | - | pagination_range, | |
| 634 | - | showing_start, | |
| 635 | - | showing_end, |
Lines truncated
| @@ -30,6 +30,14 @@ pub struct DiscoverResultsTemplate { | |||
| 30 | 30 | pub current_category: String, | |
| 31 | 31 | /// Whether the current user is authenticated (for collection save buttons). | |
| 32 | 32 | pub is_authenticated: bool, | |
| 33 | + | /// The sidebar, re-rendered for an out-of-band swap. Its counts describe the | |
| 34 | + | /// filter that produced these results, so leaving the previous sidebar in | |
| 35 | + | /// place would show numbers that disagree with the list beside them. | |
| 36 | + | pub sidebar: SidebarView, | |
| 37 | + | /// Emit the sidebar as an out-of-band swap. False when the full page | |
| 38 | + | /// includes this partial, which renders the sidebar itself; true only for | |
| 39 | + | /// the standalone `/discover/results` response. | |
| 40 | + | pub oob_sidebar: bool, | |
| 33 | 41 | } | |
| 34 | 42 | ||
| 35 | 43 | /// HTMX partial: dismissible alert/notification banner. |
| @@ -601,64 +601,24 @@ pub struct DiscoverTemplate { | |||
| 601 | 601 | pub projects: Vec<DiscoverProject>, | |
| 602 | 602 | /// Active browse mode: `"items"` or `"projects"`. | |
| 603 | 603 | pub mode: String, | |
| 604 | - | /// Item type facets for sidebar (e.g. text, audio, download). | |
| 605 | - | pub type_filters: Vec<FilterCategory>, | |
| 606 | - | /// Tag facets for sidebar (top tags by count). | |
| 607 | - | pub tag_filters: Vec<FilterCategory>, | |
| 608 | - | /// Project category facets for sidebar (projects mode only). | |
| 609 | - | pub category_filters: Vec<FilterCategory>, | |
| 610 | - | pub price_filters: Vec<PriceFilter>, | |
| 611 | 604 | pub total_items: u32, | |
| 612 | 605 | pub current_page: u32, | |
| 613 | 606 | pub total_pages: u32, | |
| 614 | 607 | pub search_query: String, | |
| 615 | 608 | /// Active sort key (e.g. `"most_sold"`, `"newest"`, `"price_asc"`). | |
| 616 | 609 | pub sort_by: String, | |
| 617 | - | /// Currently selected item_type filter, or empty for "All". | |
| 618 | - | /// One entry per selected item type; rendered as one hidden input each so a | |
| 619 | - | /// multi-select round-trips through the form without a delimiter convention. | |
| 620 | - | pub current_types: Vec<String>, | |
| 621 | - | /// Currently selected tag slug filter, or empty for "All". | |
| 622 | - | /// One entry per selected tag; see `current_types`. | |
| 623 | - | pub current_tags: Vec<String>, | |
| 624 | - | /// Selected tags with no visible control in the current view, carried as | |
| 625 | - | /// hidden inputs. Excludes anything already rendered as a checkbox, which | |
| 626 | - | /// would otherwise submit the value twice. | |
| 627 | - | pub hidden_tags: Vec<String>, | |
| 628 | - | /// Selected item types with no visible checkbox; see `hidden_tags`. | |
| 629 | - | pub hidden_types: Vec<String>, | |
| 630 | - | /// Selected tags as removable chips (also the active-filter summary). | |
| 631 | - | pub tag_chips: Vec<TagChip>, | |
| 632 | - | /// Immediate children of the drill-down cursor, with subtree counts. | |
| 633 | - | pub tag_drill: Vec<TagDrillRow>, | |
| 634 | - | /// Trail back up from the drill-down cursor; empty at the root. | |
| 635 | - | pub tag_crumbs: Vec<TagCrumb>, | |
| 636 | - | /// Cursor-move URL with the current selection preserved, ready for a slug. | |
| 637 | - | pub browse_url_prefix: String, | |
| 638 | - | /// Cursor-clear URL, selection preserved. | |
| 639 | - | pub browse_url_root: String, | |
| 640 | - | /// The five price ranges as clickable links. | |
| 641 | - | pub price_buckets: Vec<PriceBucket>, | |
| 642 | - | /// Applied price bounds, rendered back into the number inputs so | |
| 643 | - | /// `hx-include` cannot resend them blank and drop the filter. | |
| 644 | - | pub current_min_price: String, | |
| 645 | - | pub current_max_price: String, | |
| 646 | - | /// Currently selected category slug filter, or empty for "All". | |
| 647 | - | pub current_category: String, | |
| 648 | 610 | /// Page numbers to render in the pagination bar. | |
| 649 | 611 | pub pagination_range: Vec<u32>, | |
| 650 | 612 | pub showing_start: u32, | |
| 651 | 613 | pub showing_end: u32, | |
| 652 | - | /// AI tier facets for sidebar (items mode only). | |
| 653 | - | pub ai_tier_filters: Vec<FilterCategory>, | |
| 654 | - | /// Currently selected AI tier filter slug, or empty for "All". | |
| 655 | - | pub current_ai_tier: String, | |
| 656 | - | /// Whether the "has source code" filter is active (projects mode). | |
| 657 | - | pub has_source: bool, | |
| 658 | - | /// Number of active filters (for mobile filter toggle badge). | |
| 659 | - | pub active_filter_count: u32, | |
| 614 | + | /// Everything the sidebar renders. Grouped so the results partial can carry | |
| 615 | + | /// the identical set for its out-of-band swap. | |
| 616 | + | pub sidebar: SidebarView, | |
| 660 | 617 | /// Whether the current user is authenticated (for collection save buttons in results). | |
| 661 | 618 | pub is_authenticated: bool, | |
| 619 | + | /// Always false here: the page renders the sidebar directly, so the | |
| 620 | + | /// included results partial must not emit a second out-of-band copy. | |
| 621 | + | pub oob_sidebar: bool, | |
| 662 | 622 | } | |
| 663 | 623 | ||
| 664 | 624 | /// Tag tree browser with breadcrumb navigation. |
| @@ -118,3 +118,33 @@ pub struct PriceFilter { | |||
| 118 | 118 | pub label: String, | |
| 119 | 119 | pub count: u32, | |
| 120 | 120 | } | |
| 121 | + | ||
| 122 | + | /// Everything the discover sidebar renders. | |
| 123 | + | /// | |
| 124 | + | /// Grouped rather than flattened onto the templates because both the full page | |
| 125 | + | /// and the `/discover/results` partial need the whole set: the sidebar is | |
| 126 | + | /// swapped out-of-band on every filter change, so its counts and drill-down | |
| 127 | + | /// have to be recomputed alongside the results they describe. | |
| 128 | + | pub struct SidebarView { | |
| 129 | + | pub type_filters: Vec<FilterCategory>, | |
| 130 | + | pub tag_filters: Vec<FilterCategory>, | |
| 131 | + | pub category_filters: Vec<FilterCategory>, | |
| 132 | + | pub price_filters: Vec<PriceFilter>, | |
| 133 | + | pub price_buckets: Vec<PriceBucket>, | |
| 134 | + | pub ai_tier_filters: Vec<FilterCategory>, | |
| 135 | + | pub tag_chips: Vec<TagChip>, | |
| 136 | + | pub tag_drill: Vec<TagDrillRow>, | |
| 137 | + | pub tag_crumbs: Vec<TagCrumb>, | |
| 138 | + | pub hidden_tags: Vec<String>, | |
| 139 | + | pub hidden_types: Vec<String>, | |
| 140 | + | pub current_types: Vec<String>, | |
| 141 | + | pub current_tags: Vec<String>, | |
| 142 | + | pub current_min_price: String, | |
| 143 | + | pub current_max_price: String, | |
| 144 | + | pub current_category: String, | |
| 145 | + | pub current_ai_tier: String, | |
| 146 | + | pub browse_url_prefix: String, | |
| 147 | + | pub browse_url_root: String, | |
| 148 | + | pub has_source: bool, | |
| 149 | + | pub active_filter_count: u32, | |
| 150 | + | } |
| @@ -8,236 +8,11 @@ | |||
| 8 | 8 | ||
| 9 | 9 | <h1 class="page-title">Discover</h1> | |
| 10 | 10 | ||
| 11 | - | <div class="discover-layout{% if mode == "projects" && category_filters.is_empty() %} no-sidebar{% endif %}"> | |
| 12 | - | <aside class="discover-sidebar" aria-label="Filters"> | |
| 13 | - | {% if mode == "projects" %} | |
| 14 | - | <div class="filter-section"> | |
| 15 | - | <div class="filter-title" id="category-label">Category</div> | |
| 16 | - | <ul class="filter-list" aria-labelledby="category-label"> | |
| 17 | - | {% for cf in category_filters %} | |
| 18 | - | <li class="filter-item{% if cf.active %} is-selected{% endif %}"> | |
| 19 | - | <button type="button" class="filter-btn" | |
| 20 | - | aria-pressed="{% if cf.active %}true{% else %}false{% endif %}" | |
| 21 | - | hx-get="/discover/results" | |
| 22 | - | hx-target="#results-container" | |
| 23 | - | hx-indicator="#search-spinner" | |
| 24 | - | hx-include=".discover-filter" | |
| 25 | - | hx-vals='{"category": "{{ cf.value }}"}'> | |
| 26 | - | <span>{{ cf.name }}</span> <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span> | |
| 27 | - | </button> | |
| 28 | - | </li> | |
| 29 | - | {% endfor %} | |
| 30 | - | </ul> | |
| 31 | - | </div> | |
| 32 | - | <div class="filter-section"> | |
| 33 | - | <label class="filter-checkbox discover-filter-checkbox"> | |
| 34 | - | <input type="checkbox" id="has-source-input" name="has_source" | |
| 35 | - | class="discover-filter" | |
| 36 | - | value="1" | |
| 37 | - | {% if has_source %}checked{% endif %} | |
| 38 | - | hx-get="/discover/results" | |
| 39 | - | hx-trigger="change" | |
| 40 | - | hx-target="#results-container" | |
| 41 | - | hx-indicator="#search-spinner" | |
| 42 | - | hx-include=".discover-filter"> | |
| 43 | - | Has source code | |
| 44 | - | </label> | |
| 45 | - | </div> | |
| 46 | - | {% endif %} | |
| 47 | - | {% if mode == "items" %} | |
| 48 | - | {# ---- Tag spine ------------------------------------------------- | |
| 49 | - | Tags are the structure of this sidebar, not one facet among | |
| 50 | - | several: a typeahead to reach any tag at any depth, chips for | |
| 51 | - | what is selected, and a drill-down to browse by hand. | |
| 52 | - | Everything below the spine refines it. ---- #} | |
| 53 | - | <div class="filter-section tag-spine"> | |
| 54 | - | <div class="filter-title" id="tag-label">Tags</div> | |
| 55 | - | ||
| 56 | - | {# Selected tags. Also the active-filter summary and clear-all. | |
| 57 | - | Each dismiss is a plain link carrying the remaining selection, | |
| 58 | - | so removal works with JS off. #} | |
| 59 | - | {% if !tag_chips.is_empty() %} | |
| 60 | - | <ul class="tag-chips" aria-label="Selected tags"> | |
| 61 | - | {% for chip in tag_chips %} | |
| 62 | - | <li class="tag-chip"> | |
| 63 | - | <span class="tag-chip-label" {% if !chip.context.is_empty() %}title="{{ chip.context }}"{% endif %}>{{ chip.label }}</span> | |
| 64 | - | <a class="tag-chip-remove" | |
| 65 | - | href="/discover?mode=items{% if !chip.remove_query.is_empty() %}&{{ chip.remove_query|safe }}{% endif %}" | |
| 66 | - | aria-label="Remove tag {{ chip.label }}">×</a> | |
| 67 | - | </li> | |
| 68 | - | {% endfor %} | |
| 69 | - | <li class="tag-chip tag-chip-clear"> | |
| 70 | - | <a href="/discover?mode=items">Clear all</a> | |
| 71 | - | </li> | |
| 72 | - | </ul> | |
| 73 | - | {% endif %} | |
| 74 | - | ||
| 75 | - | {# Typeahead. Reaches a tag at any depth by name, which is what | |
| 76 | - | makes a taxonomy of arbitrary size navigable and what the old | |
| 77 | - | capped ten-tag list plus "Browse all" could not do. #} | |
| 78 | - | <div class="tag-combobox"> | |
| 79 | - | <label class="visually-hidden" for="tag-search">Find a tag</label> | |
| 80 | - | <input type="search" id="tag-search" class="tag-combobox-input" | |
| 81 | - | placeholder="Find a tag" autocomplete="off" | |
| 82 | - | role="combobox" aria-expanded="false" | |
| 83 | - | aria-controls="tag-suggest-list" aria-autocomplete="list"> | |
| 84 | - | <ul id="tag-suggest-list" class="tag-suggest-list" role="listbox" hidden></ul> | |
| 85 | - | </div> | |
| 86 | - | ||
| 87 | - | {# Drill-down. `browse` moves the cursor, `tag` selects; a | |
| 88 | - | category navigates, a depth-3+ leaf filters. #} | |
| 89 | - | <nav class="tag-drill" aria-labelledby="tag-label"> | |
| 90 | - | {% if !tag_crumbs.is_empty() %} | |
| 91 | - | <ol class="tag-crumbs"> | |
| 92 | - | <li><a href="{{ browse_url_root }}">All</a></li> | |
| 93 | - | {% for crumb in tag_crumbs %} | |
| 94 | - | <li><a href="{{ browse_url_prefix }}{{ crumb.slug }}">{{ crumb.label }}</a></li> | |
| 95 | - | {% endfor %} | |
| 96 | - | </ol> | |
| 97 | - | {% endif %} | |
| 98 | - | <ul class="filter-list"> | |
| 99 | - | {% for row in tag_drill %} | |
| 100 | - | <li class="filter-item{% if row.selected %} is-selected{% endif %}"> | |
| 101 | - | {% if row.assignable %} | |
| 102 | - | {# A real checkbox: multi-select is the point, and it | |
| 103 | - | is keyboard-operable without any hx-trigger work. #} | |
| 104 | - | <label class="tag-drill-select"> | |
| 105 | - | <input type="checkbox" name="tag" value="{{ row.slug }}" | |
| 106 | - | class="discover-filter" data-facet="tag" | |
| 107 | - | {% if row.selected %}checked{% endif %} | |
| 108 | - | hx-get="/discover/results" | |
| 109 | - | hx-target="#results-container" | |
| 110 | - | hx-indicator="#search-spinner" | |
| 111 | - | hx-include=".discover-filter" | |
| 112 | - | hx-trigger="change"> | |
| 113 | - | <span>{{ row.label }}</span> | |
| 114 | - | <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span> | |
| 115 | - | </label> | |
| 116 | - | {% endif %} | |
| 117 | - | {% if row.has_children %} | |
| 118 | - | <a class="tag-drill-into" href="{{ browse_url_prefix }}{{ row.slug }}" | |
| 119 | - | aria-label="Browse inside {{ row.label }}"> | |
| 120 | - | {% if !row.assignable %}<span>{{ row.label }}</span> | |
| 121 | - | <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %} | |
| 122 | - | <span class="tag-drill-chevron" aria-hidden="true">›</span> | |
| 123 | - | </a> | |
| 124 | - | {% endif %} | |
| 125 | - | </li> | |
| 126 | - | {% endfor %} | |
| 127 | - | </ul> | |
| 128 | - | </nav> | |
| 129 | - | </div> | |
| 130 | - | ||
| 131 | - | {# ---- Refine ---------------------------------------------------- | |
| 132 | - | Everything below the spine narrows what the tags selected. Same | |
| 133 | - | multi-select treatment, deliberately subordinate. Every control | |
| 134 | - | here is a real form input: multi-select needs one, and it makes | |
| 135 | - | the whole sidebar keyboard-operable without any hx-trigger work | |
| 136 | - | (the .filter-btn elements these replace were not). ---- #} | |
| 137 | - | <div class="filter-refine"> | |
| 138 | - | <div class="filter-section"> | |
| 139 | - | <fieldset class="filter-fieldset"> | |
| 140 | - | <legend class="filter-title">Type</legend> | |
| 141 | - | <ul class="filter-list"> | |
| 142 | - | {% for tf in type_filters %} | |
| 143 | - | {% if !tf.value.is_empty() %} | |
| 144 | - | <li class="filter-item{% if tf.active %} is-selected{% endif %}"> | |
| 145 | - | <label class="filter-check"> | |
| 146 | - | <input type="checkbox" name="item_type" value="{{ tf.value }}" | |
| 147 | - | class="discover-filter" data-facet="item_type" | |
| 148 | - | {% if tf.active %}checked{% endif %} | |
| 149 | - | {% if tf.count == 0 && !tf.active %}disabled{% endif %} | |
| 150 | - | hx-get="/discover/results" | |
| 151 | - | hx-target="#results-container" | |
| 152 | - | hx-indicator="#search-spinner" | |
| 153 | - | hx-include=".discover-filter" | |
| 154 | - | hx-trigger="change"> | |
| 155 | - | <span>{{ tf.name }}</span> | |
| 156 | - | <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span> | |
| 157 | - | </label> | |
| 158 | - | </li> | |
| 159 | - | {% endif %} | |
| 160 | - | {% endfor %} | |
| 161 | - | </ul> | |
| 162 | - | </fieldset> | |
| 163 | - | </div> | |
| 164 | - | ||
| 165 | - | <div class="filter-section"> | |
| 166 | - | <div class="filter-title" id="price-label">Price</div> | |
| 167 | - | <div class="price-inputs" role="group" aria-labelledby="price-label"> | |
| 168 | - | <label for="min-price" class="sr-only">Minimum price</label> | |
| 169 | - | {# The applied bound is rendered back in: hx-include resends | |
| 170 | - | these on every request, so a blank input would silently | |
| 171 | - | clear the filter on the next interaction. #} | |
| 172 | - | <input type="number" id="min-price" name="min_price" placeholder="0" min="0" | |
| 173 | - | value="{{ current_min_price }}" | |
| 174 | - | class="discover-filter" | |
| 175 | - | aria-label="Minimum price" | |
| 176 | - | hx-get="/discover/results" | |
| 177 | - | hx-trigger="change delay:500ms" | |
| 178 | - | hx-target="#results-container" | |
| 179 | - | hx-indicator="#search-spinner" | |
| 180 | - | hx-include=".discover-filter"> | |
| 181 | - | <span aria-hidden="true">to</span> | |
| 182 | - | <label for="max-price" class="sr-only">Maximum price</label> | |
| 183 | - | <input type="number" id="max-price" name="max_price" placeholder="Any" | |
| 184 | - | value="{{ current_max_price }}" | |
| 185 | - | class="discover-filter" | |
| 186 | - | aria-label="Maximum price" | |
| 187 | - | hx-get="/discover/results" | |
| 188 | - | hx-trigger="change delay:500ms" | |
| 189 | - | hx-target="#results-container" | |
| 190 | - | hx-indicator="#search-spinner" | |
| 191 | - | hx-include=".discover-filter"> | |
| 192 | - | </div> | |
| 193 | - | {# Buckets are links, not form state, so they cannot disagree | |
| 194 | - | with the number inputs above and they work without JS. #} | |
| 195 | - | <ul class="filter-list price-buckets" aria-label="Price ranges"> | |
| 196 | - | {% for pb in price_buckets %} | |
| 197 | - | <li class="filter-item{% if pb.active %} is-selected{% endif %}"> | |
| 198 | - | <a class="price-bucket" href="{{ pb.url }}" | |
| 199 | - | {% if pb.active %}aria-current="true"{% endif %}> | |
| 200 | - | <span>{{ pb.label }}</span> | |
| 201 | - | <span class="count" aria-label="{{ pb.count }} items">{{ pb.count }}</span> | |
| 202 | - | </a> | |
| 203 | - | </li> | |
| 204 | - | {% endfor %} | |
| 205 | - | </ul> | |
| 206 | - | </div> | |
| 207 | - | ||
| 208 | - | {% if !ai_tier_filters.is_empty() %} | |
| 209 | - | <div class="filter-section"> | |
| 210 | - | <fieldset class="filter-fieldset"> | |
| 211 | - | {# Radios, not checkboxes: the three tiers are nested | |
| 212 | - | ranges, so OR-ing any two only widens to the looser. #} | |
| 213 | - | <legend class="filter-title">AI Disclosure</legend> | |
| 214 | - | <ul class="filter-list"> | |
| 215 | - | {% for af in ai_tier_filters %} | |
| 216 | - | <li class="filter-item{% if af.active %} is-selected{% endif %}"> | |
| 217 | - | <label class="filter-check"> | |
| 218 | - | <input type="radio" name="ai_tier" value="{{ af.value }}" | |
| 219 | - | class="discover-filter" | |
| 220 | - | {% if af.active %}checked{% endif %} | |
| 221 | - | hx-get="/discover/results" | |
| 222 | - | hx-target="#results-container" | |
| 223 | - | hx-indicator="#search-spinner" | |
| 224 | - | hx-include=".discover-filter" | |
| 225 | - | hx-trigger="change"> | |
| 226 | - | <span>{{ af.name }}</span> | |
| 227 | - | <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span> | |
| 228 | - | </label> | |
| 229 | - | </li> | |
| 230 | - | {% endfor %} | |
| 231 | - | </ul> | |
| 232 | - | </fieldset> | |
| 233 | - | </div> | |
| 234 | - | {% endif %} | |
| 235 | - | </div> | |
| 236 | - | {% endif %} | |
| 237 | - | </aside> | |
| 11 | + | <div class="discover-layout{% if mode == "projects" && sidebar.category_filters.is_empty() %} no-sidebar{% endif %}"> | |
| 12 | + | {% include "partials/discover_sidebar.html" %} | |
| 238 | 13 | ||
| 239 | 14 | <button type="button" class="discover-filter-toggle" data-action="toggleDiscoverFilters" aria-label="Toggle filters"> | |
| 240 | - | Filters{% if active_filter_count > 0 %} <span class="filter-count">{{ active_filter_count }}</span>{% endif %} | |
| 15 | + | Filters{% if sidebar.active_filter_count > 0 %} <span class="filter-count">{{ sidebar.active_filter_count }}</span>{% endif %} | |
| 241 | 16 | </button> | |
| 242 | 17 | ||
| 243 | 18 | <main class="discover-main"> | |
| @@ -312,14 +87,14 @@ | |||
| 312 | 87 | shows one rung at a time, so a tag chosen elsewhere in the tree | |
| 313 | 88 | has no checkbox to carry it. Anything that DOES have a checked | |
| 314 | 89 | checkbox is deliberately absent here, or it would submit twice. #} | |
| 315 | - | {% for value in hidden_types %} | |
| 90 | + | {% for value in sidebar.hidden_types %} | |
| 316 | 91 | <input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type"> | |
| 317 | 92 | {% endfor %} | |
| 318 | - | {% for value in hidden_tags %} | |
| 93 | + | {% for value in sidebar.hidden_tags %} | |
| 319 | 94 | <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag"> | |
| 320 | 95 | {% endfor %} | |
| 321 | - | <input type="hidden" id="category-input" name="category" value="{{ current_category }}" class="discover-filter"> | |
| 322 | - | <input type="hidden" id="ai-tier-input" name="ai_tier" value="{{ current_ai_tier }}" class="discover-filter"> | |
| 96 | + | <input type="hidden" id="category-input" name="category" value="{{ sidebar.current_category }}" class="discover-filter"> | |
| 97 | + | <input type="hidden" id="ai-tier-input" name="ai_tier" value="{{ sidebar.current_ai_tier }}" class="discover-filter"> | |
| 323 | 98 | </div> | |
| 324 | 99 | </form> | |
| 325 | 100 |
| @@ -1,3 +1,22 @@ | |||
| 1 | + | {# The sidebar rides along on every results swap, out-of-band, so its counts | |
| 2 | + | and drill-down always describe the results below. Without this the sidebar | |
| 3 | + | is stale from the first filter click until a full page load, which is fatal | |
| 4 | + | for faceted browsing: the numbers are what the user steers by. | |
| 5 | + | ||
| 6 | + | htmx restores focus after a swap to an element with the same id, so every | |
| 7 | + | control in the sidebar carries a stable one and keyboard position survives. | |
| 8 | + | The typeahead input additionally uses hx-preserve, since its value is user | |
| 9 | + | state the server does not render. | |
| 10 | + | ||
| 11 | + | Guarded on `oob_sidebar`: the full page includes this same partial inside | |
| 12 | + | #results-container, and rendering the sidebar there too would emit it twice | |
| 13 | + | with duplicate ids. Only the standalone /discover/results response sets it. #} | |
| 14 | + | {% if oob_sidebar && mode == "items" %} | |
| 15 | + | <div hx-swap-oob="outerHTML:#discover-sidebar"> | |
| 16 | + | {% include "partials/discover_sidebar.html" %} | |
| 17 | + | </div> | |
| 18 | + | {% endif %} | |
| 19 | + | ||
| 1 | 20 | <div class="results-container results-list" id="results-container-inner"> | |
| 2 | 21 | <!-- List View --> | |
| 3 | 22 | <div class="results-table" id="results-table"> |
| @@ -0,0 +1,232 @@ | |||
| 1 | + | {# Discover sidebar. Rendered by the full page and, for the out-of-band | |
| 2 | + | swap, by partials/discover_results.html. Both templates expose the same | |
| 3 | + | `sidebar` field so this include works unchanged in either context. #} | |
| 4 | + | <aside class="discover-sidebar" id="discover-sidebar" aria-label="Filters"> | |
| 5 | + | {% if mode == "projects" %} | |
| 6 | + | <div class="filter-section"> | |
| 7 | + | <div class="filter-title" id="category-label">Category</div> | |
| 8 | + | <ul class="filter-list" aria-labelledby="category-label"> | |
| 9 | + | {% for cf in sidebar.category_filters %} | |
| 10 | + | <li class="filter-item{% if cf.active %} is-selected{% endif %}"> | |
| 11 | + | <button type="button" class="filter-btn" | |
| 12 | + | aria-pressed="{% if cf.active %}true{% else %}false{% endif %}" | |
| 13 | + | hx-get="/discover/results" | |
| 14 | + | hx-target="#results-container" | |
| 15 | + | hx-indicator="#search-spinner" | |
| 16 | + | hx-include=".discover-filter" | |
| 17 | + | hx-vals='{"category": "{{ cf.value }}"}'> | |
| 18 | + | <span>{{ cf.name }}</span> <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span> | |
| 19 | + | </button> | |
| 20 | + | </li> | |
| 21 | + | {% endfor %} | |
| 22 | + | </ul> | |
| 23 | + | </div> | |
| 24 | + | <div class="filter-section"> | |
| 25 | + | <label class="filter-checkbox discover-filter-checkbox"> | |
| 26 | + | <input type="checkbox" id="has-source-input" name="sidebar.has_source" | |
| 27 | + | class="discover-filter" | |
| 28 | + | value="1" | |
| 29 | + | {% if sidebar.has_source %}checked{% endif %} | |
| 30 | + | hx-get="/discover/results" | |
| 31 | + | hx-trigger="change" | |
| 32 | + | hx-target="#results-container" | |
| 33 | + | hx-indicator="#search-spinner" | |
| 34 | + | hx-include=".discover-filter"> | |
| 35 | + | Has source code | |
| 36 | + | </label> | |
| 37 | + | </div> | |
| 38 | + | {% endif %} | |
| 39 | + | {% if mode == "items" %} | |
| 40 | + | {# ---- Tag spine ------------------------------------------------- | |
| 41 | + | Tags are the structure of this sidebar, not one facet among | |
| 42 | + | several: a typeahead to reach any tag at any depth, chips for | |
| 43 | + | what is selected, and a drill-down to browse by hand. | |
| 44 | + | Everything below the spine refines it. ---- #} | |
| 45 | + | <div class="filter-section tag-spine"> | |
| 46 | + | <div class="filter-title" id="tag-label">Tags</div> | |
| 47 | + | ||
| 48 | + | {# Selected tags. Also the active-filter summary and clear-all. | |
| 49 | + | Each dismiss is a plain link carrying the remaining selection, | |
| 50 | + | so removal works with JS off. #} | |
| 51 | + | {% if !sidebar.tag_chips.is_empty() %} | |
| 52 | + | <ul class="tag-chips" aria-label="Selected tags"> | |
| 53 | + | {% for chip in sidebar.tag_chips %} | |
| 54 | + | <li class="tag-chip"> | |
| 55 | + | <span class="tag-chip-label" {% if !chip.context.is_empty() %}title="{{ chip.context }}"{% endif %}>{{ chip.label }}</span> | |
| 56 | + | <a class="tag-chip-remove" | |
| 57 | + | href="/discover?mode=items{% if !chip.remove_query.is_empty() %}&{{ chip.remove_query|safe }}{% endif %}" | |
| 58 | + | aria-label="Remove tag {{ chip.label }}">×</a> | |
| 59 | + | </li> | |
| 60 | + | {% endfor %} | |
| 61 | + | <li class="tag-chip tag-chip-clear"> | |
| 62 | + | <a href="/discover?mode=items">Clear all</a> | |
| 63 | + | </li> | |
| 64 | + | </ul> | |
| 65 | + | {% endif %} | |
| 66 | + | ||
| 67 | + | {# Typeahead. Reaches a tag at any depth by name, which is what | |
| 68 | + | makes a taxonomy of arbitrary size navigable and what the old | |
| 69 | + | capped ten-tag list plus "Browse all" could not do. #} | |
| 70 | + | <div class="tag-combobox"> | |
| 71 | + | <label class="visually-hidden" for="tag-search">Find a tag</label> | |
| 72 | + | <input type="search" id="tag-search" class="tag-combobox-input" hx-preserve="true" | |
| 73 | + | placeholder="Find a tag" autocomplete="off" | |
| 74 | + | role="combobox" aria-expanded="false" | |
| 75 | + | aria-controls="tag-suggest-list" aria-autocomplete="list"> | |
| 76 | + | <ul id="tag-suggest-list" class="tag-suggest-list" role="listbox" hidden></ul> | |
| 77 | + | </div> | |
| 78 | + | ||
| 79 | + | {# Drill-down. `browse` moves the cursor, `tag` selects; a | |
| 80 | + | category navigates, a depth-3+ leaf filters. #} | |
| 81 | + | <nav class="tag-drill" aria-labelledby="tag-label"> | |
| 82 | + | {% if !sidebar.tag_crumbs.is_empty() %} | |
| 83 | + | <ol class="tag-crumbs"> | |
| 84 | + | <li><a href="{{ sidebar.browse_url_root }}">All</a></li> | |
| 85 | + | {% for crumb in sidebar.tag_crumbs %} | |
| 86 | + | <li><a href="{{ sidebar.browse_url_prefix }}{{ crumb.slug }}">{{ crumb.label }}</a></li> | |
| 87 | + | {% endfor %} | |
| 88 | + | </ol> | |
| 89 | + | {% endif %} | |
| 90 | + | <ul class="filter-list"> | |
| 91 | + | {% for row in sidebar.tag_drill %} | |
| 92 | + | <li class="filter-item{% if row.selected %} is-selected{% endif %}"> | |
| 93 | + | {% if row.assignable %} | |
| 94 | + | {# A real checkbox: multi-select is the point, and it | |
| 95 | + | is keyboard-operable without any hx-trigger work. #} | |
| 96 | + | <label class="tag-drill-select"> | |
| 97 | + | <input type="checkbox" name="tag" value="{{ row.slug }}" | |
| 98 | + | id="tagsel-{{ row.slug }}" | |
| 99 | + | class="discover-filter" data-facet="tag" | |
| 100 | + | {% if row.selected %}checked{% endif %} | |
| 101 | + | hx-get="/discover/results" | |
| 102 | + | hx-target="#results-container" | |
| 103 | + | hx-indicator="#search-spinner" | |
| 104 | + | hx-include=".discover-filter" | |
| 105 | + | hx-trigger="change"> | |
| 106 | + | <span>{{ row.label }}</span> | |
| 107 | + | <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span> | |
| 108 | + | </label> | |
| 109 | + | {% endif %} | |
| 110 | + | {% if row.has_children %} | |
| 111 | + | <a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}" | |
| 112 | + | aria-label="Browse inside {{ row.label }}"> | |
| 113 | + | {% if !row.assignable %}<span>{{ row.label }}</span> | |
| 114 | + | <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %} | |
| 115 | + | <span class="tag-drill-chevron" aria-hidden="true">›</span> | |
| 116 | + | </a> | |
| 117 | + | {% endif %} | |
| 118 | + | </li> | |
| 119 | + | {% endfor %} | |
| 120 | + | </ul> | |
| 121 | + | </nav> | |
| 122 | + | </div> | |
| 123 | + | ||
| 124 | + | {# ---- Refine ---------------------------------------------------- | |
| 125 | + | Everything below the spine narrows what the tags selected. Same | |
| 126 | + | multi-select treatment, deliberately subordinate. Every control | |
| 127 | + | here is a real form input: multi-select needs one, and it makes | |
| 128 | + | the whole sidebar keyboard-operable without any hx-trigger work | |
| 129 | + | (the .filter-btn elements these replace were not). ---- #} | |
| 130 | + | <div class="filter-refine"> | |
| 131 | + | <div class="filter-section"> | |
| 132 | + | <fieldset class="filter-fieldset"> | |
| 133 | + | <legend class="filter-title">Type</legend> | |
| 134 | + | <ul class="filter-list"> | |
| 135 | + | {% for tf in sidebar.type_filters %} | |
| 136 | + | {% if !tf.value.is_empty() %} | |
| 137 | + | <li class="filter-item{% if tf.active %} is-selected{% endif %}"> | |
| 138 | + | <label class="filter-check"> | |
| 139 | + | <input type="checkbox" name="item_type" value="{{ tf.value }}" | |
| 140 | + | id="typesel-{{ tf.value }}" | |
| 141 | + | class="discover-filter" data-facet="item_type" | |
| 142 | + | {% if tf.active %}checked{% endif %} | |
| 143 | + | {% if tf.count == 0 && !tf.active %}disabled{% endif %} | |
| 144 | + | hx-get="/discover/results" | |
| 145 | + | hx-target="#results-container" | |
| 146 | + | hx-indicator="#search-spinner" | |
| 147 | + | hx-include=".discover-filter" | |
| 148 | + | hx-trigger="change"> | |
| 149 | + | <span>{{ tf.name }}</span> | |
| 150 | + | <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span> | |
| 151 | + | </label> | |
| 152 | + | </li> | |
| 153 | + | {% endif %} | |
| 154 | + | {% endfor %} | |
| 155 | + | </ul> | |
| 156 | + | </fieldset> | |
| 157 | + | </div> | |
| 158 | + | ||
| 159 | + | <div class="filter-section"> | |
| 160 | + | <div class="filter-title" id="price-label">Price</div> | |
| 161 | + | <div class="price-inputs" role="group" aria-labelledby="price-label"> | |
| 162 | + | <label for="min-price" class="sr-only">Minimum price</label> | |
| 163 | + | {# The applied bound is rendered back in: hx-include resends | |
| 164 | + | these on every request, so a blank input would silently | |
| 165 | + | clear the filter on the next interaction. #} | |
| 166 | + | <input type="number" id="min-price" name="min_price" placeholder="0" min="0" | |
| 167 | + | value="{{ sidebar.current_min_price }}" | |
| 168 | + | class="discover-filter" | |
| 169 | + | aria-label="Minimum price" | |
| 170 | + | hx-get="/discover/results" | |
| 171 | + | hx-trigger="change delay:500ms" | |
| 172 | + | hx-target="#results-container" | |
| 173 | + | hx-indicator="#search-spinner" | |
| 174 | + | hx-include=".discover-filter"> | |
| 175 | + | <span aria-hidden="true">to</span> | |
| 176 | + | <label for="max-price" class="sr-only">Maximum price</label> | |
| 177 | + | <input type="number" id="max-price" name="max_price" placeholder="Any" | |
| 178 | + | value="{{ sidebar.current_max_price }}" | |
| 179 | + | class="discover-filter" | |
| 180 | + | aria-label="Maximum price" | |
| 181 | + | hx-get="/discover/results" | |
| 182 | + | hx-trigger="change delay:500ms" | |
| 183 | + | hx-target="#results-container" | |
| 184 | + | hx-indicator="#search-spinner" | |
| 185 | + | hx-include=".discover-filter"> | |
| 186 | + | </div> | |
| 187 | + | {# Buckets are links, not form state, so they cannot disagree | |
| 188 | + | with the number inputs above and they work without JS. #} | |
| 189 | + | <ul class="filter-list price-buckets" aria-label="Price ranges"> | |
| 190 | + | {% for pb in sidebar.price_buckets %} | |
| 191 | + | <li class="filter-item{% if pb.active %} is-selected{% endif %}"> | |
| 192 | + | <a class="price-bucket" href="{{ pb.url }}" | |
| 193 | + | {% if pb.active %}aria-current="true"{% endif %}> | |
| 194 | + | <span>{{ pb.label }}</span> | |
| 195 | + | <span class="count" aria-label="{{ pb.count }} items">{{ pb.count }}</span> | |
| 196 | + | </a> | |
| 197 | + | </li> | |
| 198 | + | {% endfor %} | |
| 199 | + | </ul> | |
| 200 | + | </div> | |
| 201 | + | ||
| 202 | + | {% if !sidebar.ai_tier_filters.is_empty() %} | |
| 203 | + | <div class="filter-section"> | |
| 204 | + | <fieldset class="filter-fieldset"> | |
| 205 | + | {# Radios, not checkboxes: the three tiers are nested | |
| 206 | + | ranges, so OR-ing any two only widens to the looser. #} | |
| 207 | + | <legend class="filter-title">AI Disclosure</legend> | |
| 208 | + | <ul class="filter-list"> | |
| 209 | + | {% for af in sidebar.ai_tier_filters %} | |
| 210 | + | <li class="filter-item{% if af.active %} is-selected{% endif %}"> | |
| 211 | + | <label class="filter-check"> | |
| 212 | + | <input type="radio" name="ai_tier" value="{{ af.value }}" | |
| 213 | + | id="aitier-{{ af.value }}" | |
| 214 | + | class="discover-filter" | |
| 215 | + | {% if af.active %}checked{% endif %} | |
| 216 | + | hx-get="/discover/results" | |
| 217 | + | hx-target="#results-container" | |
| 218 | + | hx-indicator="#search-spinner" | |
| 219 | + | hx-include=".discover-filter" | |
| 220 | + | hx-trigger="change"> | |
| 221 | + | <span>{{ af.name }}</span> | |
| 222 | + | <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span> | |
| 223 | + | </label> | |
| 224 | + | </li> | |
| 225 | + | {% endfor %} | |
| 226 | + | </ul> | |
| 227 | + | </fieldset> | |
| 228 | + | </div> | |
| 229 | + | {% endif %} | |
| 230 | + | </div> | |
| 231 | + | {% endif %} | |
| 232 | + | </aside> |
| @@ -442,6 +442,72 @@ async fn price_filter_round_trips_into_its_inputs() { | |||
| 442 | 442 | } | |
| 443 | 443 | ||
| 444 | 444 | // --------------------------------------------------------------------------- | |
| 445 | + | // Out-of-band sidebar refresh. | |
| 446 | + | // --------------------------------------------------------------------------- | |
| 447 | + | ||
| 448 | + | /// The results partial carries the sidebar for an out-of-band swap. | |
| 449 | + | /// | |
| 450 | + | /// Faceted browsing is steered by the counts, so a sidebar that only refreshes | |
| 451 | + | /// on a full page load is showing numbers for a filter the user has already | |
| 452 | + | /// moved past. | |
| 453 | + | #[tokio::test] | |
| 454 | + | async fn results_partial_carries_the_sidebar_out_of_band() { | |
| 455 | + | let mut h = TestHarness::new().await; | |
| 456 | + | make_discoverable_item(&mut h, "oobsidebar", "OOB Item", "audio").await; | |
| 457 | + | ||
| 458 | + | let resp = h.client.get("/discover/results?mode=items").await; | |
| 459 | + | assert!(resp.status.is_success(), "{}", resp.status); | |
| 460 | + | assert!( | |
| 461 | + | resp.text.contains(r#"hx-swap-oob="outerHTML:#discover-sidebar""#), | |
| 462 | + | "results must carry an OOB swap for the sidebar" | |
| 463 | + | ); | |
| 464 | + | assert!( | |
| 465 | + | resp.text.contains(r#"id="discover-sidebar""#), | |
| 466 | + | "the OOB payload must include the sidebar element itself" | |
| 467 | + | ); | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | /// The out-of-band sidebar reflects the filter that produced the results. | |
| 471 | + | #[tokio::test] | |
| 472 | + | async fn oob_sidebar_counts_track_the_active_filter() { | |
| 473 | + | let mut h = TestHarness::new().await; | |
| 474 | + | make_discoverable_item(&mut h, "oobaudio", "An Audio", "audio").await; | |
| 475 | + | make_discoverable_item(&mut h, "oobvideo", "A Video", "video").await; | |
| 476 | + | ||
| 477 | + | // Unfiltered: both types present, so the audio checkbox is not checked. | |
| 478 | + | let all = h.client.get("/discover/results?mode=items").await; | |
| 479 | + | assert!(all.text.contains(r#"id="typesel-audio""#), "audio facet should render"); | |
| 480 | + | assert!(all.text.contains(r#"id="typesel-video""#), "video facet should render"); | |
| 481 | + | ||
| 482 | + | // Filtered to audio: the audio control comes back checked in the OOB payload. | |
| 483 | + | let audio = h.client.get("/discover/results?mode=items&item_type=audio").await; | |
| 484 | + | assert!( | |
| 485 | + | audio.text.contains("checked"), | |
| 486 | + | "the OOB sidebar must reflect the active selection, not the previous one" | |
| 487 | + | ); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | /// Every sidebar control carries a stable id. | |
| 491 | + | /// | |
| 492 | + | /// htmx restores focus after a swap only to an element that has one. Without | |
| 493 | + | /// ids, replacing the sidebar on every filter change would drop a keyboard | |
| 494 | + | /// user back to the document body each time they tick a box. | |
| 495 | + | #[tokio::test] | |
| 496 | + | async fn sidebar_controls_have_stable_ids_for_focus_restoration() { | |
| 497 | + | let mut h = TestHarness::new().await; | |
| 498 | + | make_discoverable_item(&mut h, "focusid", "Focus Item", "audio").await; | |
| 499 | + | ||
| 500 | + | let resp = h.client.get("/discover?mode=items").await; | |
| 501 | + | assert!(resp.status.is_success(), "{}", resp.status); | |
| 502 | + | assert!(resp.text.contains(r#"id="typesel-audio""#), "type checkboxes need ids"); | |
| 503 | + | assert!(resp.text.contains(r#"id="aitier-"#), "ai tier radios need ids"); | |
| 504 | + | assert!( | |
| 505 | + | resp.text.contains(r#"hx-preserve="true""#), | |
| 506 | + | "the typeahead input holds unrendered user state and must be preserved" | |
| 507 | + | ); | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | // --------------------------------------------------------------------------- | |
| 445 | 511 | // Tag spine: drill-down and typeahead. | |
| 446 | 512 | // --------------------------------------------------------------------------- | |
| 447 | 513 |