max / makenotwork
10 files changed,
+995 insertions,
-153 deletions
| @@ -65,3 +65,23 @@ pub struct DbTagCount { | |||
| 65 | 65 | pub tag_slug: String, | |
| 66 | 66 | pub count: i64, | |
| 67 | 67 | } | |
| 68 | + | ||
| 69 | + | /// One rung of the discover sidebar's tag drill-down: an immediate child of the | |
| 70 | + | /// browse cursor, with the number of matching items anywhere in its subtree. | |
| 71 | + | /// | |
| 72 | + | /// `count` is a subtree total, not a direct-assignment total, because the | |
| 73 | + | /// drill-down's promise is "pick this and you get that many". Items are only | |
| 74 | + | /// ever tagged with depth-3+ leaves, so a category's direct count is always | |
| 75 | + | /// zero and would render a sidebar full of noise. | |
| 76 | + | #[derive(Debug, Clone, FromRow)] | |
| 77 | + | pub struct DbTagChild { | |
| 78 | + | pub tag_name: String, | |
| 79 | + | pub tag_slug: String, | |
| 80 | + | pub count: i64, | |
| 81 | + | /// True once this tag is deep enough to be assigned to an item, and | |
| 82 | + | /// therefore to act as a filter rather than only as a navigation rung. | |
| 83 | + | pub assignable: bool, | |
| 84 | + | /// True when this tag has children of its own, so the UI knows whether | |
| 85 | + | /// selecting it should also offer to drill further. | |
| 86 | + | pub has_children: bool, | |
| 87 | + | } |
| @@ -205,6 +205,123 @@ pub async fn get_tag_counts( | |||
| 205 | 205 | Ok(counts) | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | + | /// Every tag slug, for building a [`tagtree::TagIndex`]. | |
| 209 | + | /// | |
| 210 | + | /// Ordering is irrelevant to the index (it sorts internally) but is stable here | |
| 211 | + | /// so the query plan and any logging stay predictable. | |
| 212 | + | #[tracing::instrument(skip_all)] | |
| 213 | + | pub async fn all_tag_slugs(pool: &PgPool) -> Result<Vec<String>> { | |
| 214 | + | let slugs = sqlx::query_scalar::<_, String>("SELECT slug FROM tags ORDER BY slug") | |
| 215 | + | .fetch_all(pool) | |
| 216 | + | .await?; | |
| 217 | + | Ok(slugs) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | /// Display names for a set of slugs, for rendering selected-tag chips. | |
| 221 | + | #[tracing::instrument(skip_all)] | |
| 222 | + | pub async fn tag_names_for_slugs(pool: &PgPool, slugs: &[String]) -> Result<Vec<(String, String)>> { | |
| 223 | + | if slugs.is_empty() { | |
| 224 | + | return Ok(Vec::new()); | |
| 225 | + | } | |
| 226 | + | let rows = sqlx::query_as::<_, (String, String)>( | |
| 227 | + | "SELECT slug, name FROM tags WHERE slug = ANY($1)", | |
| 228 | + | ) | |
| 229 | + | .bind(slugs) | |
| 230 | + | .fetch_all(pool) | |
| 231 | + | .await?; | |
| 232 | + | Ok(rows) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// The immediate children of `prefix` (or the depth-1 roots when `None`), each | |
| 236 | + | /// with the number of matching items anywhere in its subtree. | |
| 237 | + | /// | |
| 238 | + | /// This is the drill-down rung of the discover sidebar. The subtree rollup is | |
| 239 | + | /// what makes it useful: items carry only depth-3+ leaves, so counting direct | |
| 240 | + | /// assignments would show 0 against every category. | |
| 241 | + | /// | |
| 242 | + | /// Every filter *except* the tag facet applies, for the same reason the other | |
| 243 | + | /// facet counts exclude their own axis (see [`super::discover::FacetAxis`]): | |
| 244 | + | /// these counts answer "what would I get if I drilled in here". | |
| 245 | + | #[tracing::instrument(skip_all)] | |
| 246 | + | pub async fn tag_children_with_counts( | |
| 247 | + | pool: &PgPool, | |
| 248 | + | prefix: Option<&str>, | |
| 249 | + | filters: &super::discover::DiscoverFilters<'_>, | |
| 250 | + | ) -> Result<Vec<DbTagChild>> { | |
| 251 | + | use super::discover::{FacetAxis, append_facet_filters, is_short_query, normalize_search}; | |
| 252 | + | ||
| 253 | + | let search_term = normalize_search(filters.search); | |
| 254 | + | let has_search = search_term.is_some(); | |
| 255 | + | let short_query = search_term.as_deref().is_some_and(is_short_query); | |
| 256 | + | ||
| 257 | + | // The count is a correlated scalar subquery rather than a LEFT JOIN + GROUP BY | |
| 258 | + | // so the visibility predicate and the cross-facet filters can be plain inner | |
| 259 | + | // joins referencing i/p/u, exactly as every other discover query writes them. | |
| 260 | + | // A category with no matching items still returns a row, with 0. | |
| 261 | + | let mut count_subquery = String::from( | |
| 262 | + | r#"(SELECT COUNT(DISTINCT i.id) | |
| 263 | + | FROM item_tags it | |
| 264 | + | JOIN tags d ON d.id = it.tag_id | |
| 265 | + | JOIN items i ON i.id = it.item_id | |
| 266 | + | JOIN projects p ON i.project_id = p.id | |
| 267 | + | JOIN users u ON p.user_id = u.id | |
| 268 | + | WHERE (d.id = c.id OR d.path LIKE c.path || '.%')"#, | |
| 269 | + | ); | |
| 270 | + | count_subquery.push_str(&super::discover::ITEM_VISIBILITY_WHERE.replace(" WHERE ", " AND ")); | |
| 271 | + | append_facet_filters( | |
| 272 | + | &mut count_subquery, | |
| 273 | + | filters, | |
| 274 | + | FacetAxis::Tag, | |
| 275 | + | has_search, | |
| 276 | + | short_query, | |
| 277 | + | ); | |
| 278 | + | count_subquery.push(')'); | |
| 279 | + | ||
| 280 | + | // $7 is the browse cursor. `c.parent_id IS NULL` selects the type roots. | |
| 281 | + | let mut query = format!( | |
| 282 | + | r#" | |
| 283 | + | SELECT | |
| 284 | + | c.name AS tag_name, | |
| 285 | + | c.slug AS tag_slug, | |
| 286 | + | {count_subquery} AS count, | |
| 287 | + | (array_length(string_to_array(c.slug, '.'), 1) >= 3) AS assignable, | |
| 288 | + | EXISTS (SELECT 1 FROM tags k WHERE k.parent_id = c.id) AS has_children | |
| 289 | + | FROM tags c | |
| 290 | + | "# | |
| 291 | + | ); | |
| 292 | + | query.push_str(if prefix.is_some() { | |
| 293 | + | " WHERE c.parent_id = (SELECT id FROM tags WHERE slug = $7)" | |
| 294 | + | } else { | |
| 295 | + | " WHERE c.parent_id IS NULL" | |
| 296 | + | }); | |
| 297 | + | query.push_str(" ORDER BY c.sort_order, c.name"); | |
| 298 | + | ||
| 299 | + | let rows = sqlx::query_as::<_, DbTagChild>(&query) | |
| 300 | + | .bind(search_term.as_deref().unwrap_or("")) | |
| 301 | + | .bind( | |
| 302 | + | filters | |
| 303 | + | .item_types | |
| 304 | + | .iter() | |
| 305 | + | .map(|t| t.to_string()) | |
| 306 | + | .collect::<Vec<_>>(), | |
| 307 | + | ) | |
| 308 | + | .bind(filters.min_price.unwrap_or(0)) | |
| 309 | + | .bind(filters.max_price.unwrap_or(i32::MAX)) | |
| 310 | + | .bind(filters.tags.to_vec()) | |
| 311 | + | .bind( | |
| 312 | + | filters | |
| 313 | + | .tags | |
| 314 | + | .iter() | |
| 315 | + | .map(|s| tagtree::like_descendant_pattern(s)) | |
| 316 | + | .collect::<Vec<_>>(), | |
| 317 | + | ) | |
| 318 | + | .bind(prefix.unwrap_or("")) | |
| 319 | + | .fetch_all(pool) | |
| 320 | + | .await?; | |
| 321 | + | ||
| 322 | + | Ok(rows) | |
| 323 | + | } | |
| 324 | + | ||
| 208 | 325 | /// Get a tag by its URL slug (dot-notation, e.g. `audio.genre.electronic`). | |
| 209 | 326 | /// | |
| 210 | 327 | /// Takes `&str` rather than `&Slug` because tag slugs use dot-notation |
| @@ -10,7 +10,7 @@ use sqlx::PgPool; | |||
| 10 | 10 | use tower_sessions::Session; | |
| 11 | 11 | ||
| 12 | 12 | use std::collections::HashMap; | |
| 13 | - | use std::sync::{Mutex, OnceLock}; | |
| 13 | + | use std::sync::{Arc, Mutex, OnceLock}; | |
| 14 | 14 | use std::time::{Duration, Instant}; | |
| 15 | 15 | ||
| 16 | 16 | use crate::{ | |
| @@ -103,6 +103,95 @@ async fn cached_facets(db: &PgPool, filters: &db::discover::DiscoverFilters<'_>) | |||
| 103 | 103 | Ok(bundle) | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | + | /// Timestamped index, so the TTL check and the shared handle travel together. | |
| 107 | + | type CachedTagIndex = Option<(Instant, Arc<tagtree::TagIndex>)>; | |
| 108 | + | ||
| 109 | + | /// Memoized [`tagtree::TagIndex`] over every tag slug, backing the sidebar's | |
| 110 | + | /// tag typeahead. | |
| 111 | + | /// | |
| 112 | + | /// The index is memory-only and has to be rebuilt from Postgres; `rebuild` | |
| 113 | + | /// exists for exactly this. It is refreshed wholesale on a TTL rather than | |
| 114 | + | /// mutated, which also sidesteps `TagIndex::remove` leaving orphaned segments | |
| 115 | + | /// behind (tagtree lib.rs:817) and degrading the segment-prefix gate over time. | |
| 116 | + | /// | |
| 117 | + | /// The taxonomy is ~120 tags and changes only by migration, so five minutes of | |
| 118 | + | /// staleness in an autocomplete is not worth invalidation machinery. | |
| 119 | + | static TAG_INDEX: OnceLock<Mutex<CachedTagIndex>> = OnceLock::new(); | |
| 120 | + | ||
| 121 | + | const TAG_INDEX_TTL: Duration = Duration::from_secs(300); | |
| 122 | + | ||
| 123 | + | async fn cached_tag_index(db: &PgPool) -> Result<Arc<tagtree::TagIndex>> { | |
| 124 | + | let cell = TAG_INDEX.get_or_init(|| Mutex::new(None)); | |
| 125 | + | if let Ok(guard) = cell.lock() | |
| 126 | + | && let Some((at, index)) = guard.as_ref() | |
| 127 | + | && at.elapsed() < TAG_INDEX_TTL | |
| 128 | + | { | |
| 129 | + | return Ok(Arc::clone(index)); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | let slugs = db::tags::all_tag_slugs(db).await?; | |
| 133 | + | let index = Arc::new(tagtree::TagIndex::new(slugs)); | |
| 134 | + | ||
| 135 | + | if let Ok(mut guard) = cell.lock() { | |
| 136 | + | *guard = Some((Instant::now(), Arc::clone(&index))); | |
| 137 | + | } | |
| 138 | + | Ok(index) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// How many typeahead results to return. Enough to fill the dropdown without | |
| 142 | + | /// turning `suggest_fuzzy`'s full-corpus scan into a page-weight problem. | |
| 143 | + | const TAG_SUGGEST_LIMIT: usize = 8; | |
| 144 | + | ||
| 145 | + | /// Tag typeahead for the discover sidebar. | |
| 146 | + | /// | |
| 147 | + | /// Prefix matching first (path-prefix, then segment-prefix), falling back to | |
| 148 | + | /// fuzzy only when prefix matching underfills. That ordering is tagtree's, and | |
| 149 | + | /// it is why typing "elec" finds `audio.genre.electronic` without the caller | |
| 150 | + | /// knowing which level it lives at. | |
| 151 | + | pub(super) async fn tag_suggestions_handler( | |
| 152 | + | State(db): State<PgPool>, | |
| 153 | + | Query(query): Query<SuggestionsQuery>, | |
| 154 | + | ) -> Result<impl IntoResponse> { | |
| 155 | + | let raw = query.q.unwrap_or_default(); | |
| 156 | + | let input = raw.trim(); | |
| 157 | + | if input.is_empty() { | |
| 158 | + | return Ok(Json(Vec::<TagSuggestion>::new())); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | let index = cached_tag_index(&db).await?; | |
| 162 | + | let (hits, _exact) = index.suggest_with_status(input, TAG_SUGGEST_LIMIT); | |
| 163 | + | let mut slugs: Vec<String> = hits.into_iter().map(str::to_string).collect(); | |
| 164 | + | if slugs.is_empty() { | |
| 165 | + | slugs = index | |
| 166 | + | .suggest_fuzzy(input, TAG_SUGGEST_LIMIT) | |
| 167 | + | .into_iter() | |
| 168 | + | .map(str::to_string) | |
| 169 | + | .collect(); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Only depth-3+ tags can be assigned to an item, so only they can filter | |
| 173 | + | // anything; offering a category here would produce an empty result set. | |
| 174 | + | slugs.retain(|s| tagtree::depth(s) >= 3); | |
| 175 | + | ||
| 176 | + | let names: std::collections::HashMap<String, String> = | |
| 177 | + | db::tags::tag_names_for_slugs(&db, &slugs).await?.into_iter().collect(); | |
| 178 | + | ||
| 179 | + | let suggestions: Vec<TagSuggestion> = slugs | |
| 180 | + | .into_iter() | |
| 181 | + | .map(|slug| { | |
| 182 | + | let label = names.get(&slug).cloned().unwrap_or_else(|| { | |
| 183 | + | tagtree::leaf(&slug).replace('-', " ") | |
| 184 | + | }); | |
| 185 | + | // The parent path orients an otherwise ambiguous leaf: "Format" | |
| 186 | + | // appears under audio, software, writing, and video. | |
| 187 | + | let context = tagtree::parent(&slug).unwrap_or("").to_string(); | |
| 188 | + | TagSuggestion { slug, label, context } | |
| 189 | + | }) | |
| 190 | + | .collect(); | |
| 191 | + | ||
| 192 | + | Ok(Json(suggestions)) | |
| 193 | + | } | |
| 194 | + | ||
| 106 | 195 | /// Deserialize an empty string as `None` instead of failing to parse. | |
| 107 | 196 | /// | |
| 108 | 197 | /// HTML form inputs send `field=` (empty string) when blank, which fails | |
| @@ -144,6 +233,11 @@ pub struct DiscoverQuery { | |||
| 144 | 233 | pub mode: Option<String>, // "items" (default) or "projects" | |
| 145 | 234 | pub ai_tier: Option<String>, | |
| 146 | 235 | pub has_source: Option<String>, | |
| 236 | + | /// Drill-down cursor: which tag's children the sidebar is showing. Distinct | |
| 237 | + | /// from `tag`, which is the selection. Browsing into a category does not | |
| 238 | + | /// filter, and selecting a leaf does not move the cursor, so the two can be | |
| 239 | + | /// operated independently. | |
| 240 | + | pub browse: Option<String>, | |
| 147 | 241 | } | |
| 148 | 242 | ||
| 149 | 243 | impl DiscoverQuery { | |
| @@ -156,6 +250,61 @@ impl DiscoverQuery { | |||
| 156 | 250 | /// address bar. Prices are sanitized so the URL states what was actually | |
| 157 | 251 | /// applied, and `page=1` is left implicit. | |
| 158 | 252 | fn to_page_url(&self) -> String { | |
| 253 | + | let mut parts = self.filter_params(); | |
| 254 | + | if let Some(b) = self.browse.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { | |
| 255 | + | parts.push(format!("browse={}", urlencoding::encode(b))); | |
| 256 | + | } | |
| 257 | + | if let Some(p) = self.page.filter(|&p| p > 1) { | |
| 258 | + | parts.push(format!("page={p}")); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | if parts.is_empty() { | |
| 262 | + | "/discover".to_string() | |
| 263 | + | } else { | |
| 264 | + | format!("/discover?{}", parts.join("&")) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// Base URL for moving the drill-down cursor, ending ready for a `browse=` | |
| 269 | + | /// value to be appended. | |
| 270 | + | /// | |
| 271 | + | /// Navigating the tree preserves the current selection and drops `page`, | |
| 272 | + | /// since a cursor move lands you on a different list and page 4 of the old | |
| 273 | + | /// one is meaningless. | |
| 274 | + | fn browse_base_url(&self) -> String { | |
| 275 | + | let parts = self.filter_params(); | |
| 276 | + | if parts.is_empty() { | |
| 277 | + | "/discover?browse=".to_string() | |
| 278 | + | } else { | |
| 279 | + | format!("/discover?{}&browse=", parts.join("&")) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /// The URL that clears the cursor back to the tag roots, selection intact. | |
| 284 | + | fn browse_root_url(&self) -> String { | |
| 285 | + | let parts = self.filter_params(); | |
| 286 | + | if parts.is_empty() { | |
| 287 | + | "/discover".to_string() | |
| 288 | + | } else { | |
| 289 | + | format!("/discover?{}", parts.join("&")) | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | /// Filter params with the price range omitted, for building the price | |
| 294 | + | /// bucket links: a bucket replaces the range rather than adding to it. | |
| 295 | + | fn params_without_price(&self) -> Vec<String> { | |
| 296 | + | let (min, max) = sanitize_price_range(self.min_price, self.max_price); | |
| 297 | + | self.filter_params() | |
| 298 | + | .into_iter() | |
| 299 | + | .filter(|p| { | |
| 300 | + | !(min.is_some_and(|v| *p == format!("min_price={v}")) | |
| 301 | + | || max.is_some_and(|v| *p == format!("max_price={v}"))) | |
| 302 | + | }) | |
| 303 | + | .collect() | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | /// Every filter param except the cursor and the page, in canonical order. | |
| 307 | + | fn filter_params(&self) -> Vec<String> { | |
| 159 | 308 | fn push_str(parts: &mut Vec<String>, key: &str, value: Option<&String>) { | |
| 160 | 309 | if let Some(v) = value.map(|s| s.trim()).filter(|s| !s.is_empty()) { | |
| 161 | 310 | parts.push(format!("{key}={}", urlencoding::encode(v))); | |
| @@ -187,15 +336,8 @@ impl DiscoverQuery { | |||
| 187 | 336 | if let Some(v) = max_price { | |
| 188 | 337 | parts.push(format!("max_price={v}")); | |
| 189 | 338 | } | |
| 190 | - | if let Some(p) = self.page.filter(|&p| p > 1) { | |
| 191 | - | parts.push(format!("page={p}")); | |
| 192 | - | } | |
| 193 | 339 | ||
| 194 | - | if parts.is_empty() { | |
| 195 | - | "/discover".to_string() | |
| 196 | - | } else { | |
| 197 | - | format!("/discover?{}", parts.join("&")) | |
| 198 | - | } | |
| 340 | + | parts | |
| 199 | 341 | } | |
| 200 | 342 | } | |
| 201 | 343 | ||
| @@ -259,6 +401,7 @@ mod page_url_tests { | |||
| 259 | 401 | mode: None, | |
| 260 | 402 | ai_tier: None, | |
| 261 | 403 | has_source: None, | |
| 404 | + | browse: None, | |
| 262 | 405 | } | |
| 263 | 406 | } | |
| 264 | 407 | ||
| @@ -618,6 +761,21 @@ pub(super) async fn discover( | |||
| 618 | 761 | vec![] | |
| 619 | 762 | }; | |
| 620 | 763 | ||
| 764 | + | // The applied bounds, rendered back into the number inputs. Without this the | |
| 765 | + | // inputs come back blank, hx-include resends them empty, and the price filter | |
| 766 | + | // silently disappears on the next interaction. | |
| 767 | + | let (shown_min, shown_max) = sanitize_price_range(query.min_price, query.max_price); | |
| 768 | + | let current_min_price = shown_min.map(|v| v.to_string()).unwrap_or_default(); | |
| 769 | + | let current_max_price = shown_max.map(|v| v.to_string()).unwrap_or_default(); | |
| 770 | + | ||
| 771 | + | // Built before the template literal partially moves `query`. | |
| 772 | + | let browse_url_prefix = query.browse_base_url(); | |
| 773 | + | let browse_url_root = query.browse_root_url(); | |
| 774 | + | ||
| 775 | + | let mut tag_chips: Vec<TagChip> = Vec::new(); | |
| 776 | + | let mut tag_drill: Vec<TagDrillRow> = Vec::new(); | |
| 777 | + | let mut tag_crumbs: Vec<TagCrumb> = Vec::new(); | |
| 778 | + | ||
| 621 | 779 | let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" { | |
| 622 | 780 | // The four facet counts are viewer-independent -> served from a short-TTL | |
| 623 | 781 | // memo (ultra-fuzz Run 12 Performance). Only `followed_tag_ids` is | |
| @@ -634,6 +792,68 @@ pub(super) async fn discover( | |||
| 634 | 792 | }; | |
| 635 | 793 | let (type_counts, tag_counts, ai_counts, price_counts) = | |
| 636 | 794 | cached_facets(&db, &facet_filters).await?; | |
| 795 | + | ||
| 796 | + | // The drill-down cursor. Not cached alongside the facets: it varies by | |
| 797 | + | // an axis the facet key does not carry, and it is one cheap indexed | |
| 798 | + | // query over a ~120-row table. | |
| 799 | + | let browse_cursor = query.browse.as_deref().filter(|s| !s.is_empty()); | |
| 800 | + | let drill_rows = | |
| 801 | + | db::tags::tag_children_with_counts(&db, browse_cursor, &facet_filters).await?; | |
| 802 | + | tag_drill = drill_rows | |
| 803 | + | .into_iter() | |
| 804 | + | .map(|r| TagDrillRow { | |
| 805 | + | selected: tag_filter.iter().any(|t| t == &r.tag_slug), | |
| 806 | + | slug: r.tag_slug, | |
| 807 | + | label: r.tag_name, | |
| 808 | + | count: r.count as u32, | |
| 809 | + | assignable: r.assignable, | |
| 810 | + | has_children: r.has_children, | |
| 811 | + | }) | |
| 812 | + | .collect(); | |
| 813 | + | ||
| 814 | + | // Breadcrumbs are derived from the cursor's own dot-path rather than | |
| 815 | + | // queried: tagtree::ancestors is exactly this, and every ancestor of a | |
| 816 | + | // valid slug is itself a valid slug. | |
| 817 | + | tag_crumbs = browse_cursor | |
| 818 | + | .map(|cursor| { | |
| 819 | + | tagtree::ancestors(cursor) | |
| 820 | + | .into_iter() | |
| 821 | + | .chain(std::iter::once(cursor)) | |
| 822 | + | .map(|slug| TagCrumb { | |
| 823 | + | slug: slug.to_string(), | |
| 824 | + | label: tagtree::leaf(slug).replace('-', " "), | |
| 825 | + | }) | |
| 826 | + | .collect() | |
| 827 | + | }) | |
| 828 | + | .unwrap_or_default(); | |
| 829 | + | ||
| 830 | + | // Chips carry a precomputed dismiss query so the template stays free of | |
| 831 | + | // list manipulation, and so removal is a plain link that works without JS. | |
| 832 | + | let chip_names: std::collections::HashMap<String, String> = | |
| 833 | + | db::tags::tag_names_for_slugs(&db, &tag_filter) | |
| 834 | + | .await? | |
| 835 | + | .into_iter() | |
| 836 | + | .collect(); | |
| 837 | + | tag_chips = tag_filter | |
| 838 | + | .iter() | |
| 839 | + | .map(|slug| { | |
| 840 | + | let remove_query = tag_filter | |
| 841 | + | .iter() | |
| 842 | + | .filter(|other| *other != slug) | |
| 843 | + | .map(|other| format!("tag={}", urlencoding::encode(other))) | |
| 844 | + | .collect::<Vec<_>>() | |
| 845 | + | .join("&"); | |
| 846 | + | TagChip { | |
| 847 | + | label: chip_names | |
| 848 | + | .get(slug) | |
| 849 | + | .cloned() | |
| 850 | + | .unwrap_or_else(|| tagtree::leaf(slug).replace('-', " ")), | |
| 851 | + | context: tagtree::parent(slug).unwrap_or("").to_string(), | |
| 852 | + | slug: slug.clone(), | |
| 853 | + | remove_query, | |
| 854 | + | } | |
| 855 | + | }) | |
| 856 | + | .collect(); | |
| 637 | 857 | // Only the first ~10 tag facets are rendered (see `.take(10)` below), so | |
| 638 | 858 | // test follow-membership against just those rather than fetching the | |
| 639 | 859 | // viewer's entire followed-tag set (fuzz 2026-07-06 C5-1). | |
| @@ -738,6 +958,40 @@ pub(super) async fn discover( | |||
| 738 | 958 | PriceFilter { label: "$100+".to_string(), count: price_counts.over_100 as u32 }, | |
| 739 | 959 | ]; | |
| 740 | 960 | ||
| 961 | + | // The same five buckets as clickable ranges. Bounds mirror the FILTER | |
| 962 | + | // clauses in db::discover::get_price_range_counts exactly; if one moves the | |
| 963 | + | // other must, or a bucket will report a count its own link cannot reproduce. | |
| 964 | + | let price_base_params = query.params_without_price(); | |
| 965 | + | let (applied_min, applied_max) = sanitize_price_range(query.min_price, query.max_price); | |
| 966 | + | let price_buckets: Vec<PriceBucket> = [ | |
| 967 | + | ("Free", price_counts.free, Some(0), Some(0)), | |
| 968 | + | ("Under $25", price_counts.under_25, Some(1), Some(2499)), | |
| 969 | + | ("$25-50", price_counts.range_25_50, Some(2500), Some(4999)), | |
| 970 | + | ("$50-100", price_counts.range_50_100, Some(5000), Some(9999)), | |
| 971 | + | ("$100+", price_counts.over_100, Some(10000), None), | |
| 972 | + | ] | |
| 973 | + | .into_iter() | |
| 974 | + | .map(|(label, count, min, max)| { | |
| 975 | + | let mut parts = price_base_params.clone(); | |
| 976 | + | if let Some(v) = min { | |
| 977 | + | parts.push(format!("min_price={v}")); | |
| 978 | + | } | |
| 979 | + | if let Some(v) = max { | |
| 980 | + | parts.push(format!("max_price={v}")); | |
| 981 | + | } | |
| 982 | + | PriceBucket { | |
| 983 | + | label: label.to_string(), | |
| 984 | + | count: count as u32, | |
| 985 | + | url: if parts.is_empty() { | |
| 986 | + | "/discover".to_string() | |
| 987 | + | } else { | |
| 988 | + | format!("/discover?{}", parts.join("&")) | |
| 989 | + | }, | |
| 990 | + | active: applied_min == min && applied_max == max, | |
| 991 | + | } | |
| 992 | + | }) | |
| 993 | + | .collect(); | |
| 994 | + | ||
| 741 | 995 | // The mobile filter badge counts *groups* with an active selection, not | |
| 742 | 996 | // individual values: picking three tags is still one filter in use. | |
| 743 | 997 | let current_types: Vec<String> = | |
| @@ -747,6 +1001,27 @@ pub(super) async fn discover( | |||
| 747 | 1001 | let current_category = query.category.unwrap_or_default(); | |
| 748 | 1002 | let current_ai_tier = query.ai_tier.unwrap_or_default(); | |
| 749 | 1003 | ||
| 1004 | + | // A selection is only carried by a hidden input when no visible control | |
| 1005 | + | // represents it. The drill-down shows one rung at a time, so a tag chosen | |
| 1006 | + | // from elsewhere in the tree has no checkbox on screen; likewise a type | |
| 1007 | + | // whose count dropped out of the facet list. Rendering a hidden input for a | |
| 1008 | + | // value that also has a checked checkbox would submit it twice. | |
| 1009 | + | let visible_tag_slugs: std::collections::HashSet<&str> = | |
| 1010 | + | tag_drill.iter().map(|r| r.slug.as_str()).collect(); | |
| 1011 | + | let hidden_tags: Vec<String> = current_tags | |
| 1012 | + | .iter() | |
| 1013 | + | .filter(|t| !visible_tag_slugs.contains(t.as_str())) | |
| 1014 | + | .cloned() | |
| 1015 | + | .collect(); | |
| 1016 | + | let visible_type_values: std::collections::HashSet<&str> = | |
| 1017 | + | type_filters.iter().map(|t| t.value.as_str()).collect(); | |
| 1018 | + | let hidden_types: Vec<String> = current_types | |
| 1019 | + | .iter() | |
| 1020 | + | .filter(|t| !visible_type_values.contains(t.as_str())) | |
| 1021 | + | .cloned() | |
| 1022 | + | .collect(); | |
| 1023 | + | ||
| 1024 | + | ||
| 750 | 1025 | let active_filter_count = [ | |
| 751 | 1026 | !current_types.is_empty(), | |
| 752 | 1027 | !current_tags.is_empty(), | |
| @@ -776,6 +1051,16 @@ pub(super) async fn discover( | |||
| 776 | 1051 | sort_by: query.sort.unwrap_or_default(), | |
| 777 | 1052 | current_types, | |
| 778 | 1053 | current_tags, | |
| 1054 | + | hidden_tags, | |
| 1055 | + | hidden_types, | |
| 1056 | + | tag_chips, | |
| 1057 | + | tag_drill, | |
| 1058 | + | tag_crumbs, | |
| 1059 | + | browse_url_prefix, | |
| 1060 | + | browse_url_root, | |
| 1061 | + | price_buckets, | |
| 1062 | + | current_min_price, | |
| 1063 | + | current_max_price, | |
| 779 | 1064 | current_category, | |
| 780 | 1065 | pagination_range: data.pagination_range, | |
| 781 | 1066 | showing_start: data.showing_start, | |
| @@ -830,6 +1115,17 @@ pub struct SuggestionsQuery { | |||
| 830 | 1115 | pub q: Option<String>, | |
| 831 | 1116 | } | |
| 832 | 1117 | ||
| 1118 | + | /// JSON response for a tag typeahead hit. | |
| 1119 | + | /// | |
| 1120 | + | /// `context` is the parent path, which the dropdown needs in order to | |
| 1121 | + | /// disambiguate: "Format" exists under audio, software, writing, and video. | |
| 1122 | + | #[derive(Debug, Serialize)] | |
| 1123 | + | pub struct TagSuggestion { | |
| 1124 | + | pub slug: String, | |
| 1125 | + | pub label: String, | |
| 1126 | + | pub context: String, | |
| 1127 | + | } | |
| 1128 | + | ||
| 833 | 1129 | /// JSON response for a search suggestion. | |
| 834 | 1130 | #[derive(Debug, Serialize)] | |
| 835 | 1131 | pub struct SearchSuggestion { |
| @@ -86,7 +86,8 @@ pub fn public_routes() -> CsrfRouter<AppState> { | |||
| 86 | 86 | .route_get("/discover", get(discover::discover)) | |
| 87 | 87 | .route_get("/discover/results", get(discover::discover_results).layer(GovernorLayer { config: search_rate_limit.clone() })) | |
| 88 | 88 | .route_get("/discover/suggestions", get(discover::search_suggestions_handler).layer(GovernorLayer { config: search_rate_limit.clone() })) | |
| 89 | - | .route_get("/discover/tags", get(discover::tag_tree).layer(GovernorLayer { config: search_rate_limit })) | |
| 89 | + | .route_get("/discover/tags", get(discover::tag_tree).layer(GovernorLayer { config: search_rate_limit.clone() })) | |
| 90 | + | .route_get("/discover/tag-suggest", get(discover::tag_suggestions_handler).layer(GovernorLayer { config: search_rate_limit })) | |
| 90 | 91 | .route_get("/feed", get(feed::feed_page)) | |
| 91 | 92 | .route_get("/u/{username}", get(content::user_page)) | |
| 92 | 93 | .route_get("/c/{username}/{slug}", get(content::collection_page)) |
| @@ -621,6 +621,28 @@ pub struct DiscoverTemplate { | |||
| 621 | 621 | /// Currently selected tag slug filter, or empty for "All". | |
| 622 | 622 | /// One entry per selected tag; see `current_types`. | |
| 623 | 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, | |
| 624 | 646 | /// Currently selected category slug filter, or empty for "All". | |
| 625 | 647 | pub current_category: String, | |
| 626 | 648 | /// Page numbers to render in the pagination bar. |
| @@ -59,6 +59,38 @@ pub struct TagTreeNode { | |||
| 59 | 59 | pub child_count: usize, | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | + | /// A selected tag, rendered as a removable chip above the sidebar's drill-down. | |
| 63 | + | /// | |
| 64 | + | /// The chip row doubles as the active-filter summary and the clear-all | |
| 65 | + | /// affordance, neither of which the pre-multi-select sidebar had. | |
| 66 | + | pub struct TagChip { | |
| 67 | + | pub slug: String, | |
| 68 | + | /// Human-readable name (`tags.name`), falling back to the de-slugified leaf. | |
| 69 | + | pub label: String, | |
| 70 | + | /// The parent path, to disambiguate leaves that share a name across types. | |
| 71 | + | pub context: String, | |
| 72 | + | /// The `?tag=` list with this chip removed, for its dismiss control. | |
| 73 | + | pub remove_query: String, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// One rung of the tag drill-down. | |
| 77 | + | pub struct TagDrillRow { | |
| 78 | + | pub slug: String, | |
| 79 | + | pub label: String, | |
| 80 | + | pub count: u32, | |
| 81 | + | /// Depth >= 3, so selecting it filters. Shallower rows only navigate. | |
| 82 | + | pub assignable: bool, | |
| 83 | + | pub has_children: bool, | |
| 84 | + | /// Already in the active selection, so the control renders as selected. | |
| 85 | + | pub selected: bool, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// A rung of the drill-down's breadcrumb trail, for navigating back up. | |
| 89 | + | pub struct TagCrumb { | |
| 90 | + | pub slug: String, | |
| 91 | + | pub label: String, | |
| 92 | + | } | |
| 93 | + | ||
| 62 | 94 | /// A breadcrumb in the tag tree hierarchy. | |
| 63 | 95 | #[derive(Clone)] | |
| 64 | 96 | pub struct TagBreadcrumb { | |
| @@ -68,6 +100,20 @@ pub struct TagBreadcrumb { | |||
| 68 | 100 | ||
| 69 | 101 | /// Price filter option | |
| 70 | 102 | #[derive(Clone)] | |
| 103 | + | /// A price bucket in the refine block. | |
| 104 | + | /// | |
| 105 | + | /// Buckets are navigation, not form state: each renders as a link carrying the | |
| 106 | + | /// `min_price`/`max_price` it represents, so they work without JS and cannot | |
| 107 | + | /// disagree with the number inputs, which stay the source of truth. | |
| 108 | + | pub struct PriceBucket { | |
| 109 | + | pub label: String, | |
| 110 | + | pub count: u32, | |
| 111 | + | /// `?min_price=&max_price=` pair for this bucket, with the rest of the | |
| 112 | + | /// filter selection preserved. | |
| 113 | + | pub url: String, | |
| 114 | + | pub active: bool, | |
| 115 | + | } | |
| 116 | + | ||
| 71 | 117 | pub struct PriceFilter { | |
| 72 | 118 | pub label: String, | |
| 73 | 119 | pub count: u32, |
| @@ -1,28 +1,120 @@ | |||
| 1 | - | // Replace the hidden inputs carrying a multi-valued facet. | |
| 2 | - | // | |
| 3 | - | // These facets are one input per selected value rather than one delimited | |
| 4 | - | // input, so the form can express a multi-select without a delimiter | |
| 5 | - | // convention that would break on a value containing the delimiter. The | |
| 6 | - | // visible controls are still single-select, so `values` currently holds at | |
| 7 | - | // most one entry; the DOM shape is what lets the server-side multi-select | |
| 8 | - | // round-trip, and what the checkbox UI will write into directly. | |
| 9 | - | function setFacetValues(facet, values) { | |
| 10 | - | var form = document.getElementById('discover-form'); | |
| 11 | - | if (!form) return; | |
| 12 | - | form.querySelectorAll('input[data-facet="' + facet + '"]').forEach(function(el) { | |
| 13 | - | el.remove(); | |
| 1 | + | // Tag typeahead. Backed by tagtree's TagIndex server-side, so a tag is | |
| 2 | + | // reachable by name from any depth without knowing where it sits. | |
| 3 | + | (function() { | |
| 4 | + | var input = document.getElementById('tag-search'); | |
| 5 | + | var list = document.getElementById('tag-suggest-list'); | |
| 6 | + | if (!input || !list) return; | |
| 7 | + | var timer = null; | |
| 8 | + | var active = -1; | |
| 9 | + | ||
| 10 | + | function close() { | |
| 11 | + | list.hidden = true; | |
| 12 | + | list.innerHTML = ''; | |
| 13 | + | input.setAttribute('aria-expanded', 'false'); | |
| 14 | + | input.removeAttribute('aria-activedescendant'); | |
| 15 | + | active = -1; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // Focus stays in the input for a combobox; aria-activedescendant is what | |
| 19 | + | // tells assistive tech which option is current. Without it the listbox | |
| 20 | + | // role is a claim the widget does not honour. | |
| 21 | + | function setActive(items, index) { | |
| 22 | + | active = index; | |
| 23 | + | items.forEach(function(el, i) { | |
| 24 | + | var on = i === index; | |
| 25 | + | el.classList.toggle('is-active', on); | |
| 26 | + | el.setAttribute('aria-selected', on ? 'true' : 'false'); | |
| 27 | + | }); | |
| 28 | + | if (index >= 0 && items[index]) { | |
| 29 | + | input.setAttribute('aria-activedescendant', items[index].id); | |
| 30 | + | } else { | |
| 31 | + | input.removeAttribute('aria-activedescendant'); | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | function choose(slug) { | |
| 36 | + | // Adding a tag is the same operation the drill-down checkbox performs, | |
| 37 | + | // so route it through the form rather than a bespoke request. | |
| 38 | + | var form = document.getElementById('discover-form'); | |
| 39 | + | if (!form) return; | |
| 40 | + | var existing = form.querySelector('input[data-facet="tag"][value="' + CSS.escape(slug) + '"]'); | |
| 41 | + | if (!existing) { | |
| 42 | + | var el = document.createElement('input'); | |
| 43 | + | el.type = 'hidden'; | |
| 44 | + | el.name = 'tag'; | |
| 45 | + | el.value = slug; | |
| 46 | + | el.className = 'discover-filter'; | |
| 47 | + | el.dataset.facet = 'tag'; | |
| 48 | + | form.appendChild(el); | |
| 49 | + | } | |
| 50 | + | input.value = ''; | |
| 51 | + | close(); | |
| 52 | + | htmx.trigger(form, 'tag-added'); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | function render(items) { | |
| 56 | + | if (!items.length) { close(); return; } | |
| 57 | + | list.innerHTML = ''; | |
| 58 | + | items.forEach(function(item, i) { | |
| 59 | + | var li = document.createElement('li'); | |
| 60 | + | li.id = 'tag-suggest-option-' + i; | |
| 61 | + | li.setAttribute('role', 'option'); | |
| 62 | + | li.setAttribute('aria-selected', i === active ? 'true' : 'false'); | |
| 63 | + | li.className = 'tag-suggest-item' + (i === active ? ' is-active' : ''); | |
| 64 | + | li.dataset.slug = item.slug; | |
| 65 | + | var label = document.createElement('span'); | |
| 66 | + | label.className = 'tag-suggest-label'; | |
| 67 | + | label.textContent = item.label; | |
| 68 | + | li.appendChild(label); | |
| 69 | + | if (item.context) { | |
| 70 | + | var ctx = document.createElement('span'); | |
| 71 | + | ctx.className = 'tag-suggest-context'; | |
| 72 | + | ctx.textContent = item.context; | |
| 73 | + | li.appendChild(ctx); | |
| 74 | + | } | |
| 75 | + | li.addEventListener('mousedown', function(e) { | |
| 76 | + | e.preventDefault(); | |
| 77 | + | choose(item.slug); | |
| 78 | + | }); | |
| 79 | + | list.appendChild(li); | |
| 80 | + | }); | |
| 81 | + | list.hidden = false; | |
| 82 | + | input.setAttribute('aria-expanded', 'true'); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | input.addEventListener('input', function() { | |
| 86 | + | var q = input.value.trim(); | |
| 87 | + | clearTimeout(timer); | |
| 88 | + | if (q.length < 2) { close(); return; } | |
| 89 | + | timer = setTimeout(function() { | |
| 90 | + | fetch('/discover/tag-suggest?q=' + encodeURIComponent(q)) | |
| 91 | + | .then(function(r) { return r.ok ? r.json() : []; }) | |
| 92 | + | .then(function(items) { active = -1; render(items); }) | |
| 93 | + | .catch(function() { close(); }); | |
| 94 | + | }, 150); | |
| 95 | + | }); | |
| 96 | + | ||
| 97 | + | input.addEventListener('keydown', function(e) { | |
| 98 | + | var items = list.querySelectorAll('.tag-suggest-item'); | |
| 99 | + | if (e.key === 'Escape') { close(); return; } | |
| 100 | + | if (!items.length) return; | |
| 101 | + | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | |
| 102 | + | e.preventDefault(); | |
| 103 | + | var next = active + (e.key === 'ArrowDown' ? 1 : -1); | |
| 104 | + | if (next < 0) next = items.length - 1; | |
| 105 | + | if (next >= items.length) next = 0; | |
| 106 | + | setActive(items, next); | |
| 107 | + | } else if (e.key === 'Enter') { | |
| 108 | + | e.preventDefault(); | |
| 109 | + | var pick = active >= 0 ? items[active] : items[0]; | |
| 110 | + | if (pick) choose(pick.dataset.slug); | |
| 111 | + | } | |
| 14 | 112 | }); | |
| 15 | - | values.forEach(function(value) { | |
| 16 | - | if (!value) return; | |
| 17 | - | var input = document.createElement('input'); | |
| 18 | - | input.type = 'hidden'; | |
| 19 | - | input.name = facet; | |
| 20 | - | input.value = value; | |
| 21 | - | input.className = 'discover-filter'; | |
| 22 | - | input.dataset.facet = facet; | |
| 23 | - | form.appendChild(input); | |
| 113 | + | ||
| 114 | + | document.addEventListener('click', function(e) { | |
| 115 | + | if (!list.contains(e.target) && e.target !== input) close(); | |
| 24 | 116 | }); | |
| 25 | - | } | |
| 117 | + | })(); | |
| 26 | 118 | ||
| 27 | 119 | // Sync filter UI state on filter click: update active highlight and write | |
| 28 | 120 | // the selected filter value into the hidden form inputs so subsequent | |
| @@ -44,21 +136,13 @@ | |||
| 44 | 136 | evt.detail.elt.closest('.filter-item').classList.add('is-selected'); | |
| 45 | 137 | evt.detail.elt.setAttribute('aria-pressed', 'true'); | |
| 46 | 138 | ||
| 139 | + | // Only the projects-mode category facet still uses .filter-btn. | |
| 140 | + | // Type, tag, and AI tier are real checkboxes and radios now, so | |
| 141 | + | // they carry their own state and need no mirroring. | |
| 47 | 142 | var hxVals = JSON.parse(evt.detail.elt.getAttribute('hx-vals') || '{}'); | |
| 48 | - | if ('item_type' in hxVals) { | |
| 49 | - | setFacetValues('item_type', hxVals.item_type ? [hxVals.item_type] : []); | |
| 50 | - | } | |
| 51 | - | if ('tag' in hxVals) { | |
| 52 | - | setFacetValues('tag', hxVals.tag ? [hxVals.tag] : []); | |
| 53 | - | } | |
| 54 | 143 | if ('category' in hxVals) { | |
| 55 | - | document.getElementById('category-input').value = hxVals.category || ''; | |
| 56 | - | } | |
| 57 | - | if ('label' in hxVals) { | |
| 58 | - | document.getElementById('label-input').value = hxVals.label || ''; | |
| 59 | - | } | |
| 60 | - | if ('ai_tier' in hxVals) { | |
| 61 | - | document.getElementById('ai-tier-input').value = hxVals.ai_tier || ''; | |
| 144 | + | var categoryInput = document.getElementById('category-input'); | |
| 145 | + | if (categoryInput) categoryInput.value = hxVals.category || ''; | |
| 62 | 146 | } | |
| 63 | 147 | } | |
| 64 | 148 | }); |
| @@ -45,107 +45,195 @@ | |||
| 45 | 45 | </div> | |
| 46 | 46 | {% endif %} | |
| 47 | 47 | {% if mode == "items" %} | |
| 48 | - | <div class="filter-section"> | |
| 49 | - | <div class="filter-title" id="type-label">Type</div> | |
| 50 | - | <ul class="filter-list" aria-labelledby="type-label"> | |
| 51 | - | {% for tf in type_filters %} | |
| 52 | - | <li class="filter-item{% if tf.active %} is-selected{% endif %}"> | |
| 53 | - | <button type="button" class="filter-btn" | |
| 54 | - | aria-pressed="{% if tf.active %}true{% else %}false{% endif %}" | |
| 55 | - | hx-get="/discover/results" | |
| 56 | - | hx-target="#results-container" | |
| 57 | - | hx-indicator="#search-spinner" | |
| 58 | - | hx-include=".discover-filter" | |
| 59 | - | hx-vals='{"item_type": "{{ tf.value }}"}'> | |
| 60 | - | <span>{{ tf.name }}</span> <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span> | |
| 61 | - | </button> | |
| 62 | - | </li> | |
| 63 | - | {% endfor %} | |
| 64 | - | </ul> | |
| 65 | - | </div> | |
| 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> | |
| 66 | 55 | ||
| 67 | - | <div class="filter-section"> | |
| 68 | - | <div class="filter-title" id="tag-label">Tags <a href="/discover/tags" class="filter-browse-link">Browse all</a></div> | |
| 69 | - | <ul class="filter-list" aria-labelledby="tag-label"> | |
| 70 | - | {% for tg in tag_filters %} | |
| 71 | - | <li class="filter-item{% if tg.active %} is-selected{% endif %}"> | |
| 72 | - | <button type="button" class="filter-btn" | |
| 73 | - | aria-pressed="{% if tg.active %}true{% else %}false{% endif %}" | |
| 74 | - | hx-get="/discover/results" | |
| 75 | - | hx-target="#results-container" | |
| 76 | - | hx-indicator="#search-spinner" | |
| 77 | - | hx-include=".discover-filter" | |
| 78 | - | hx-vals='{"tag": "{{ tg.value }}"}'> | |
| 79 | - | <span>{{ tg.name }}</span> | |
| 80 | - | <span class="count" aria-label="{{ tg.count }} items">{{ tg.count }}</span> | |
| 81 | - | </button> | |
| 82 | - | {# Follow is a sibling of the filter, not a child: an interactive | |
| 83 | - | control nested inside another button is invalid and unreachable. #} | |
| 84 | - | {% if session_user.is_some() && !tg.id.is_empty() %} | |
| 85 | - | {% if tg.following %} | |
| 86 | - | <button class="tag-follow-btn is-selected" hx-delete="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Unfollow" data-action="noop" data-stop>Following</button> | |
| 87 | - | {% else %} | |
| 88 | - | <button class="tag-follow-btn" hx-post="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Follow" data-action="noop" data-stop>Follow</button> | |
| 89 | - | {% endif %} | |
| 90 | - | {% endif %} | |
| 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> | |
| 91 | 67 | </li> | |
| 92 | 68 | {% endfor %} | |
| 69 | + | <li class="tag-chip tag-chip-clear"> | |
| 70 | + | <a href="/discover?mode=items">Clear all</a> | |
| 71 | + | </li> | |
| 93 | 72 | </ul> | |
| 94 | - | </div> | |
| 73 | + | {% endif %} | |
| 95 | 74 | ||
| 96 | - | <div class="filter-section"> | |
| 97 | - | <div class="filter-title" id="price-label">Price</div> | |
| 98 | - | <div class="price-inputs" role="group" aria-labelledby="price-label"> | |
| 99 | - | <label for="min-price" class="sr-only">Minimum price</label> | |
| 100 | - | <input type="number" id="min-price" name="min_price" placeholder="0" min="0" | |
| 101 | - | class="discover-filter" | |
| 102 | - | aria-label="Minimum price" | |
| 103 | - | hx-get="/discover/results" | |
| 104 | - | hx-trigger="change delay:500ms" | |
| 105 | - | hx-target="#results-container" | |
| 106 | - | hx-indicator="#search-spinner" | |
| 107 | - | hx-include=".discover-filter"> | |
| 108 | - | <span aria-hidden="true">to</span> | |
| 109 | - | <label for="max-price" class="sr-only">Maximum price</label> | |
| 110 | - | <input type="number" id="max-price" name="max_price" placeholder="Any" | |
| 111 | - | class="discover-filter" | |
| 112 | - | aria-label="Maximum price" | |
| 113 | - | hx-get="/discover/results" | |
| 114 | - | hx-trigger="change delay:500ms" | |
| 115 | - | hx-target="#results-container" | |
| 116 | - | hx-indicator="#search-spinner" | |
| 117 | - | hx-include=".discover-filter"> | |
| 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> | |
| 118 | 85 | </div> | |
| 119 | - | <ul class="filter-list price-distribution" role="list" aria-label="Price distribution"> | |
| 120 | - | {% for pf in price_filters %} | |
| 121 | - | <li class="price-distribution-item"> | |
| 122 | - | {{ pf.label }}: {{ pf.count }} | |
| 123 | - | </li> | |
| 124 | - | {% endfor %} | |
| 125 | - | </ul> | |
| 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> | |
| 126 | 129 | </div> | |
| 127 | 130 | ||
| 128 | - | {% if !ai_tier_filters.is_empty() %} | |
| 129 | - | <div class="filter-section"> | |
| 130 | - | <div class="filter-title" id="ai-tier-label">AI Disclosure</div> | |
| 131 | - | <ul class="filter-list" aria-labelledby="ai-tier-label"> | |
| 132 | - | {% for af in ai_tier_filters %} | |
| 133 | - | <li class="filter-item{% if af.active %} is-selected{% endif %}"> | |
| 134 | - | <button type="button" class="filter-btn" | |
| 135 | - | aria-pressed="{% if af.active %}true{% else %}false{% endif %}" | |
| 136 | - | hx-get="/discover/results" | |
| 137 | - | hx-target="#results-container" | |
| 138 | - | hx-indicator="#search-spinner" | |
| 139 | - | hx-include=".discover-filter" | |
| 140 | - | hx-vals='{"ai_tier": "{{ af.value }}"}'> | |
| 141 | - | <span>{{ af.name }}</span> <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span> | |
| 142 | - | </button> | |
| 143 | - | </li> | |
| 144 | - | {% endfor %} | |
| 145 | - | </ul> | |
| 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 %} | |
| 146 | 235 | </div> | |
| 147 | 236 | {% endif %} | |
| 148 | - | {% endif %} | |
| 149 | 237 | </aside> | |
| 150 | 238 | ||
| 151 | 239 | <button type="button" class="discover-filter-toggle" data-action="toggleDiscoverFilters" aria-label="Toggle filters"> | |
| @@ -170,7 +258,15 @@ | |||
| 170 | 258 | </div> | |
| 171 | 259 | </div> | |
| 172 | 260 | ||
| 173 | - | <form id="discover-form" action="/discover" method="get"> | |
| 261 | + | {# `tag-added` is fired by the typeahead once it appends a hidden | |
| 262 | + | input, so picking a suggestion refreshes results through the same | |
| 263 | + | path as every other filter control. #} | |
| 264 | + | <form id="discover-form" action="/discover" method="get" | |
| 265 | + | hx-get="/discover/results" | |
| 266 | + | hx-target="#results-container" | |
| 267 | + | hx-indicator="#search-spinner" | |
| 268 | + | hx-include=".discover-filter" | |
| 269 | + | hx-trigger="tag-added"> | |
| 174 | 270 | <input type="hidden" id="mode-input" name="mode" value="{{ mode }}" class="discover-filter"> | |
| 175 | 271 | <div class="table-controls"> | |
| 176 | 272 | <label for="search-input" class="sr-only">Search {% if mode == "projects" %}projects{% else %}items{% endif %}</label> | |
| @@ -212,14 +308,14 @@ | |||
| 212 | 308 | <option value="price_desc"{% if sort_by == "price_desc" %} selected{% endif %}>Price desc</option> | |
| 213 | 309 | {% endif %} | |
| 214 | 310 | </select> | |
| 215 | - | {# Multi-valued facets carry one hidden input per selection, so a | |
| 216 | - | selection of several tags round-trips through hx-include without | |
| 217 | - | inventing a delimiter. page-discover.js rewrites the whole group | |
| 218 | - | for a facet on each click (see setFacetValues). #} | |
| 219 | - | {% for value in current_types %} | |
| 311 | + | {# Only selections with no visible control on screen. The drill-down | |
| 312 | + | shows one rung at a time, so a tag chosen elsewhere in the tree | |
| 313 | + | has no checkbox to carry it. Anything that DOES have a checked | |
| 314 | + | checkbox is deliberately absent here, or it would submit twice. #} | |
| 315 | + | {% for value in hidden_types %} | |
| 220 | 316 | <input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type"> | |
| 221 | 317 | {% endfor %} | |
| 222 | - | {% for value in current_tags %} | |
| 318 | + | {% for value in hidden_tags %} | |
| 223 | 319 | <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag"> | |
| 224 | 320 | {% endfor %} | |
| 225 | 321 | <input type="hidden" id="category-input" name="category" value="{{ current_category }}" class="discover-filter"> |
| @@ -414,6 +414,126 @@ async fn facet_counts_apply_the_ai_tier_filter() { | |||
| 414 | 414 | ); | |
| 415 | 415 | } | |
| 416 | 416 | ||
| 417 | + | /// The price filter survives a second interaction. | |
| 418 | + | /// | |
| 419 | + | /// The number inputs carry `class="discover-filter"`, so `hx-include` resends | |
| 420 | + | /// them on every subsequent request. Without a rendered `value`, they come back | |
| 421 | + | /// empty, `empty_string_as_none` maps that to "unfiltered", and the price filter | |
| 422 | + | /// silently disappears the moment the user touches any other control. | |
| 423 | + | #[tokio::test] | |
| 424 | + | async fn price_filter_round_trips_into_its_inputs() { | |
| 425 | + | let mut h = TestHarness::new().await; | |
| 426 | + | make_discoverable_item(&mut h, "priceround", "Priced Thing", "audio").await; | |
| 427 | + | ||
| 428 | + | let resp = h.client.get("/discover?mode=items&min_price=2500&max_price=7500").await; | |
| 429 | + | assert!(resp.status.is_success(), "{}", resp.status); | |
| 430 | + | assert!( | |
| 431 | + | resp.text.contains(r#"id="min-price""#), | |
| 432 | + | "price inputs must render" | |
| 433 | + | ); | |
| 434 | + | assert!( | |
| 435 | + | resp.text.contains(r#"value="2500""#), | |
| 436 | + | "min_price must round-trip into its input or hx-include drops it" | |
| 437 | + | ); | |
| 438 | + | assert!( | |
| 439 | + | resp.text.contains(r#"value="7500""#), | |
| 440 | + | "max_price must round-trip into its input or hx-include drops it" | |
| 441 | + | ); | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | // --------------------------------------------------------------------------- | |
| 445 | + | // Tag spine: drill-down and typeahead. | |
| 446 | + | // --------------------------------------------------------------------------- | |
| 447 | + | ||
| 448 | + | /// Drill-down counts roll up the whole subtree, not direct assignments. | |
| 449 | + | /// | |
| 450 | + | /// Items may only carry depth-3+ leaves, so a category's direct count is always | |
| 451 | + | /// zero. If the drill-down counted direct assignments every category would read | |
| 452 | + | /// 0 and the sidebar would be useless. | |
| 453 | + | #[tokio::test] | |
| 454 | + | async fn drill_down_counts_roll_up_the_subtree() { | |
| 455 | + | let mut h = TestHarness::new().await; | |
| 456 | + | let (_, item) = make_discoverable_item(&mut h, "drillroll", "Deep Item", "audio").await; | |
| 457 | + | tag_item(&h, &item, "audio.genre.electronic").await; | |
| 458 | + | ||
| 459 | + | // At the root, the `audio` type must report the leaf two levels below it. | |
| 460 | + | let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[])) | |
| 461 | + | .await | |
| 462 | + | .expect("root drill rows"); | |
| 463 | + | let audio = roots | |
| 464 | + | .iter() | |
| 465 | + | .find(|r| r.tag_slug == "audio") | |
| 466 | + | .expect("audio root present"); | |
| 467 | + | assert_eq!(audio.count, 1, "root count must roll up the whole subtree"); | |
| 468 | + | assert!(!audio.assignable, "depth-1 type roots are not assignable"); | |
| 469 | + | assert!(audio.has_children, "audio has categories beneath it"); | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | /// A category with no matching items is still listed, with a zero. | |
| 473 | + | /// | |
| 474 | + | /// Dropping empty rungs would make the taxonomy's shape flicker as filters | |
| 475 | + | /// change; showing a 0 tells the user the branch exists and is empty. | |
| 476 | + | #[tokio::test] | |
| 477 | + | async fn drill_down_keeps_empty_children_as_zero() { | |
| 478 | + | let h = TestHarness::new().await; | |
| 479 | + | // No items at all. | |
| 480 | + | let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[])) | |
| 481 | + | .await | |
| 482 | + | .expect("root drill rows"); | |
| 483 | + | assert!(!roots.is_empty(), "type roots must render even with an empty catalog"); | |
| 484 | + | assert!( | |
| 485 | + | roots.iter().all(|r| r.count == 0), | |
| 486 | + | "an empty catalog means every rung reads zero, not missing" | |
| 487 | + | ); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | /// Drilling in returns the cursor's immediate children only. | |
| 491 | + | #[tokio::test] | |
| 492 | + | async fn drill_down_returns_immediate_children_of_the_cursor() { | |
| 493 | + | let h = TestHarness::new().await; | |
| 494 | + | let rows = makenotwork::db::tags::tag_children_with_counts( | |
| 495 | + | &h.db, | |
| 496 | + | Some("audio"), | |
| 497 | + | &filters_for(&[]), | |
| 498 | + | ) | |
| 499 | + | .await | |
| 500 | + | .expect("audio children"); | |
| 501 | + | assert!(!rows.is_empty(), "audio has categories"); | |
| 502 | + | assert!( | |
| 503 | + | rows.iter().all(|r| r.tag_slug.starts_with("audio.") | |
| 504 | + | && r.tag_slug.matches('.').count() == 1), | |
| 505 | + | "only depth-2 children of audio, got {:?}", | |
| 506 | + | rows.iter().map(|r| &r.tag_slug).collect::<Vec<_>>() | |
| 507 | + | ); | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | /// The typeahead finds a leaf from a partial name, at any depth. | |
| 511 | + | #[tokio::test] | |
| 512 | + | async fn tag_typeahead_finds_a_leaf_by_partial_name() { | |
| 513 | + | let mut h = TestHarness::new().await; | |
| 514 | + | let resp = h.client.get("/discover/tag-suggest?q=electr").await; | |
| 515 | + | assert!(resp.status.is_success(), "{}", resp.status); | |
| 516 | + | assert!( | |
| 517 | + | resp.text.contains("audio.genre.electronic"), | |
| 518 | + | "prefix search must reach a depth-3 leaf, got {}", | |
| 519 | + | resp.text | |
| 520 | + | ); | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | /// The typeahead never offers a tag that cannot filter. | |
| 524 | + | #[tokio::test] | |
| 525 | + | async fn tag_typeahead_omits_unassignable_categories() { | |
| 526 | + | let mut h = TestHarness::new().await; | |
| 527 | + | // "audio" and "audio.genre" both prefix-match, but neither is assignable. | |
| 528 | + | let resp = h.client.get("/discover/tag-suggest?q=audio.genre").await; | |
| 529 | + | assert!(resp.status.is_success(), "{}", resp.status); | |
| 530 | + | assert!( | |
| 531 | + | !resp.text.contains(r#""slug":"audio.genre""#), | |
| 532 | + | "a depth-2 category must not be offered as a filter: {}", | |
| 533 | + | resp.text | |
| 534 | + | ); | |
| 535 | + | } | |
| 536 | + | ||
| 417 | 537 | // --------------------------------------------------------------------------- | |
| 418 | 538 | // Faceted multi-select: OR within a facet, AND across facets. | |
| 419 | 539 | // --------------------------------------------------------------------------- |
| @@ -181,24 +181,64 @@ async fn discover_search_replaces_rather_than_pushes_history() { | |||
| 181 | 181 | } | |
| 182 | 182 | ||
| 183 | 183 | #[tokio::test] | |
| 184 | - | async fn discover_filters_are_buttons_not_fake_options() { | |
| 184 | + | async fn discover_filters_are_real_form_controls() { | |
| 185 | 185 | let mut h = TestHarness::new().await; | |
| 186 | 186 | ||
| 187 | + | // The type facet renders one control per type that has items, so an empty | |
| 188 | + | // catalog would render no checkboxes and the assertions below would pass | |
| 189 | + | // vacuously or fail confusingly. Seed one discoverable item first. | |
| 190 | + | let setup = h.create_creator_with_item("formctl", "audio", 1000).await; | |
| 191 | + | sqlx::query( | |
| 192 | + | "UPDATE items SET is_public = true, listed = true, scan_status = 'clean', \ | |
| 193 | + | deleted_at = NULL WHERE id = $1::uuid", | |
| 194 | + | ) | |
| 195 | + | .bind(&setup.item_id) | |
| 196 | + | .execute(&h.db) | |
| 197 | + | .await | |
| 198 | + | .expect("publish item"); | |
| 199 | + | sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid") | |
| 200 | + | .bind(&setup.project_id) | |
| 201 | + | .execute(&h.db) | |
| 202 | + | .await | |
| 203 | + | .expect("publish project"); | |
| 204 | + | ||
| 187 | 205 | let resp = h.client.get("/discover?mode=items").await; | |
| 188 | 206 | assert_eq!(resp.status, 200); | |
| 189 | - | // Real buttons activate on Enter/Space with no scripting. The old | |
| 190 | - | // li[role=option][tabindex=0] took focus but could not be activated at all. | |
| 207 | + | ||
| 208 | + | // The item-mode filters are now checkboxes and radios rather than buttons | |
| 209 | + | // carrying hx-vals. Native controls are what make multi-select expressible | |
| 210 | + | // at all, and they are keyboard-operable with no scripting: the earlier | |
| 211 | + | // li[role=option][tabindex=0] took focus but could not be activated, and | |
| 212 | + | // the .filter-btn that replaced it still needed JS to mirror its value into | |
| 213 | + | // a hidden input. | |
| 191 | 214 | assert!( | |
| 192 | - | resp.text.contains("class=\"filter-btn\""), | |
| 193 | - | "Filters should render as real buttons" | |
| 215 | + | resp.text.contains(r#"type="checkbox" name="item_type""#), | |
| 216 | + | "type facet should be checkboxes (multi-select)" | |
| 194 | 217 | ); | |
| 195 | 218 | assert!( | |
| 196 | - | !resp.text.contains("role=\"option\""), | |
| 197 | - | "Filters should not claim listbox semantics they don't implement" | |
| 219 | + | resp.text.contains(r#"type="radio" name="ai_tier""#), | |
| 220 | + | "AI tier should be radios: its three tiers are nested ranges, not independent values" | |
| 221 | + | ); | |
| 222 | + | ||
| 223 | + | // The one listbox on the page is the tag typeahead, and it must declare the | |
| 224 | + | // wiring it actually implements. This assertion previously banned listbox | |
| 225 | + | // roles outright, because the only one on the page was a fake. | |
| 226 | + | assert!( | |
| 227 | + | resp.text.contains(r#"role="combobox""#), | |
| 228 | + | "the tag typeahead input should declare combobox" | |
| 229 | + | ); | |
| 230 | + | assert!( | |
| 231 | + | resp.text.contains(r#"aria-controls="tag-suggest-list""#), | |
| 232 | + | "the combobox must point at the listbox it controls" | |
| 233 | + | ); | |
| 234 | + | assert_eq!( | |
| 235 | + | resp.text.matches(r#"role="listbox""#).count(), | |
| 236 | + | 1, | |
| 237 | + | "the typeahead should be the only listbox; filter lists must not claim the role" | |
| 198 | 238 | ); | |
| 199 | 239 | assert!( | |
| 200 | - | !resp.text.contains("role=\"listbox\""), | |
| 201 | - | "Filter lists should not claim listbox semantics they don't implement" | |
| 240 | + | !resp.text.contains(r#"role="option""#), | |
| 241 | + | "options are rendered by the typeahead at runtime, never server-side" | |
| 202 | 242 | ); | |
| 203 | 243 | } | |
| 204 | 244 |