| 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 |
{# State carriers live INSIDE the sidebar so the out-of-band swap keeps |
| 6 |
them in step with the controls. They used to sit in the page form, |
| 7 |
which the swap never replaces: a tag selected from one drill rung was |
| 8 |
silently dropped as soon as the drill navigated away from its |
| 9 |
checkbox, because nothing carried it any more. |
| 10 |
|
| 11 |
Only selections with no visible control are listed; anything with a |
| 12 |
checked checkbox on screen is absent, or it would submit twice. #} |
| 13 |
{% for value in sidebar.hidden_types %} |
| 14 |
<input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type"> |
| 15 |
{% endfor %} |
| 16 |
{% for value in sidebar.hidden_tags %} |
| 17 |
<input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag"> |
| 18 |
{% endfor %} |
| 19 |
{# The drill cursor. Without this every filter interaction rebuilt the |
| 20 |
request from .discover-filter alone, which carries no cursor, so the |
| 21 |
sidebar snapped back to the tag roots on every click. #} |
| 22 |
<input type="hidden" name="browse" value="{{ sidebar.browse_cursor }}" class="discover-filter"> |
| 23 |
|
| 24 |
{% if mode == "projects" %} |
| 25 |
<div class="filter-section"> |
| 26 |
<fieldset class="filter-fieldset"> |
| 27 |
{# Radios, not buttons: `category` is single-valued server-side |
| 28 |
(DiscoverQuery.category is an Option), and a native control is |
| 29 |
keyboard-operable without mirroring its value into a hidden |
| 30 |
input from JS. The "All" entry carries the empty value, which |
| 31 |
parses back to no filter. #} |
| 32 |
<legend class="filter-title">Category</legend> |
| 33 |
<ul class="filter-list"> |
| 34 |
{% for cf in sidebar.category_filters %} |
| 35 |
<li class="filter-item{% if cf.active %} is-selected{% endif %}"> |
| 36 |
<label class="filter-check"> |
| 37 |
<input type="radio" name="category" value="{{ cf.value }}" |
| 38 |
id="catsel-{% if cf.value.is_empty() %}all{% else %}{{ cf.value }}{% endif %}" |
| 39 |
class="discover-filter" |
| 40 |
{% if cf.active %}checked{% endif %} |
| 41 |
hx-get="/discover/results" |
| 42 |
hx-target="#results-container" |
| 43 |
hx-indicator="#search-spinner" |
| 44 |
hx-include=".discover-filter" |
| 45 |
hx-trigger="change"> |
| 46 |
<span>{{ cf.name }}</span> |
| 47 |
<span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span> |
| 48 |
</label> |
| 49 |
</li> |
| 50 |
{% endfor %} |
| 51 |
</ul> |
| 52 |
</fieldset> |
| 53 |
</div> |
| 54 |
<div class="filter-section"> |
| 55 |
<label class="filter-checkbox discover-filter-checkbox"> |
| 56 |
<input type="checkbox" id="has-source-input" name="has_source" |
| 57 |
class="discover-filter" |
| 58 |
value="1" |
| 59 |
{% if sidebar.has_source %}checked{% endif %} |
| 60 |
hx-get="/discover/results" |
| 61 |
hx-trigger="change" |
| 62 |
hx-target="#results-container" |
| 63 |
hx-indicator="#search-spinner" |
| 64 |
hx-include=".discover-filter"> |
| 65 |
Has source code |
| 66 |
</label> |
| 67 |
</div> |
| 68 |
{% endif %} |
| 69 |
{% if mode == "items" %} |
| 70 |
{# ---- Tag spine ------------------------------------------------- |
| 71 |
Tags are the structure of this sidebar, not one facet among |
| 72 |
several: a typeahead to reach any tag at any depth, chips for |
| 73 |
what is selected, and a drill-down to browse by hand. |
| 74 |
Everything below the spine refines it. ---- #} |
| 75 |
<div class="filter-section tag-spine"> |
| 76 |
<div class="filter-title" id="tag-label">Tags</div> |
| 77 |
|
| 78 |
{# Selected tags. Also the active-filter summary and clear-all. |
| 79 |
Each dismiss is a plain link carrying the remaining selection, |
| 80 |
so removal works with JS off. #} |
| 81 |
{% if !sidebar.tag_chips.is_empty() %} |
| 82 |
<ul class="tag-chips" aria-label="Selected tags"> |
| 83 |
{% for chip in sidebar.tag_chips %} |
| 84 |
<li class="tag-chip"> |
| 85 |
<span class="tag-chip-label" {% if !chip.context.is_empty() %}title="{{ chip.context }}"{% endif %}>{{ chip.label }}</span> |
| 86 |
<a class="tag-chip-remove" |
| 87 |
href="/discover?mode=items{% if !chip.remove_query.is_empty() %}&{{ chip.remove_query|safe }}{% endif %}" |
| 88 |
aria-label="Remove tag {{ chip.label }}">×</a> |
| 89 |
</li> |
| 90 |
{% endfor %} |
| 91 |
<li class="tag-chip tag-chip-clear"> |
| 92 |
<a href="/discover?mode=items">Clear all</a> |
| 93 |
</li> |
| 94 |
</ul> |
| 95 |
{% endif %} |
| 96 |
|
| 97 |
{# Typeahead. Reaches a tag at any depth by name, which is what |
| 98 |
makes a taxonomy of arbitrary size navigable and what the old |
| 99 |
capped ten-tag list plus "Browse all" could not do. #} |
| 100 |
<div class="tag-combobox"> |
| 101 |
<label class="sr-only" for="tag-search">Find a tag</label> |
| 102 |
<input type="search" id="tag-search" class="tag-combobox-input" hx-preserve="true" |
| 103 |
placeholder="Find a tag" autocomplete="off" |
| 104 |
role="combobox" aria-expanded="false" |
| 105 |
aria-controls="tag-suggest-list" aria-autocomplete="list"> |
| 106 |
<ul id="tag-suggest-list" class="search-suggestions tag-suggest-list" role="listbox" hidden></ul> |
| 107 |
</div> |
| 108 |
|
| 109 |
{# Drill-down. `browse` moves the cursor, `tag` selects; a |
| 110 |
category navigates, a depth-3+ leaf filters. #} |
| 111 |
<nav class="tag-drill" aria-labelledby="tag-label"> |
| 112 |
{% if !sidebar.tag_crumbs.is_empty() %} |
| 113 |
<ol class="tag-crumbs"> |
| 114 |
<li><a href="{{ sidebar.browse_url_root }}">All</a></li> |
| 115 |
{% for crumb in sidebar.tag_crumbs %} |
| 116 |
<li><a href="{{ sidebar.browse_url_prefix }}{{ crumb.slug }}">{{ crumb.label }}</a></li> |
| 117 |
{% endfor %} |
| 118 |
</ol> |
| 119 |
{% endif %} |
| 120 |
<ul class="filter-list"> |
| 121 |
{% for row in sidebar.tag_drill %} |
| 122 |
<li class="filter-item{% if row.selected %} is-selected{% endif %}{% if row.count == 0 %} is-empty{% endif %}"> |
| 123 |
{% if row.assignable %} |
| 124 |
{# A real checkbox: multi-select is the point, and it |
| 125 |
is keyboard-operable without any hx-trigger work. #} |
| 126 |
<label class="tag-drill-select"> |
| 127 |
<input type="checkbox" name="tag" value="{{ row.slug }}" |
| 128 |
id="tagsel-{{ row.slug }}" |
| 129 |
class="discover-filter" data-facet="tag" |
| 130 |
{% if row.selected %}checked{% endif %} |
| 131 |
hx-get="/discover/results" |
| 132 |
hx-target="#results-container" |
| 133 |
hx-indicator="#search-spinner" |
| 134 |
hx-include=".discover-filter" |
| 135 |
hx-trigger="change"> |
| 136 |
<span>{{ row.label }}</span> |
| 137 |
<span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span> |
| 138 |
</label> |
| 139 |
{% endif %} |
| 140 |
{% if row.has_children %} |
| 141 |
<a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}" |
| 142 |
aria-label="Browse inside {{ row.label }}"> |
| 143 |
{% if !row.assignable %}<span>{{ row.label }}</span> |
| 144 |
<span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %} |
| 145 |
<span class="tag-drill-chevron" aria-hidden="true">›</span> |
| 146 |
</a> |
| 147 |
{% endif %} |
| 148 |
{# Follow is a SIBLING of the label, never inside it: |
| 149 |
a control nested in a label would toggle the |
| 150 |
checkbox when clicked. Offered on EVERY rung, not |
| 151 |
just assignable leaves: following is hierarchical, |
| 152 |
so a follow on a branch matches its whole subtree |
| 153 |
(see db::follows::FOLLOWED_ITEM_IDS_CTE). It is the |
| 154 |
last child so it right-aligns identically whether |
| 155 |
the row leads with a checkbox label or a drill-in |
| 156 |
link, both of which take flex: 1. #} |
| 157 |
{% if sidebar.viewer_authenticated %} |
| 158 |
{% if row.following %} |
| 159 |
<button class="tag-follow-btn is-selected" |
| 160 |
hx-delete="/api/follow/tag/{{ row.tag_id }}" |
| 161 |
hx-swap="outerHTML" |
| 162 |
aria-label="Unfollow {{ row.label }}" |
| 163 |
data-action="noop" data-stop>Following</button> |
| 164 |
{% else %} |
| 165 |
<button class="tag-follow-btn" |
| 166 |
hx-post="/api/follow/tag/{{ row.tag_id }}" |
| 167 |
hx-swap="outerHTML" |
| 168 |
aria-label="Follow {{ row.label }}" |
| 169 |
data-action="noop" data-stop>Follow</button> |
| 170 |
{% endif %} |
| 171 |
{% endif %} |
| 172 |
</li> |
| 173 |
{% endfor %} |
| 174 |
</ul> |
| 175 |
</nav> |
| 176 |
</div> |
| 177 |
|
| 178 |
{# ---- Refine ---------------------------------------------------- |
| 179 |
Everything below the spine narrows what the tags selected. Same |
| 180 |
multi-select treatment, deliberately subordinate. Every control |
| 181 |
here is a real form input: multi-select needs one, and it makes |
| 182 |
the whole sidebar keyboard-operable without any hx-trigger work |
| 183 |
(the .filter-btn elements these replace were not). ---- #} |
| 184 |
<div class="filter-refine"> |
| 185 |
<div class="filter-section"> |
| 186 |
<fieldset class="filter-fieldset"> |
| 187 |
<legend class="filter-title">Type</legend> |
| 188 |
<ul class="filter-list"> |
| 189 |
{% for tf in sidebar.type_filters %} |
| 190 |
{% if !tf.value.is_empty() %} |
| 191 |
<li class="filter-item{% if tf.active %} is-selected{% endif %}"> |
| 192 |
<label class="filter-check"> |
| 193 |
<input type="checkbox" name="item_type" value="{{ tf.value }}" |
| 194 |
id="typesel-{{ tf.value }}" |
| 195 |
class="discover-filter" data-facet="item_type" |
| 196 |
{% if tf.active %}checked{% endif %} |
| 197 |
{% if tf.count == 0 && !tf.active %}disabled{% endif %} |
| 198 |
hx-get="/discover/results" |
| 199 |
hx-target="#results-container" |
| 200 |
hx-indicator="#search-spinner" |
| 201 |
hx-include=".discover-filter" |
| 202 |
hx-trigger="change"> |
| 203 |
<span>{{ tf.name }}</span> |
| 204 |
<span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span> |
| 205 |
</label> |
| 206 |
</li> |
| 207 |
{% endif %} |
| 208 |
{% endfor %} |
| 209 |
</ul> |
| 210 |
</fieldset> |
| 211 |
</div> |
| 212 |
|
| 213 |
<div class="filter-section"> |
| 214 |
<div class="filter-title" id="price-label">Price</div> |
| 215 |
<div class="price-inputs" role="group" aria-labelledby="price-label"> |
| 216 |
<label for="min-price" class="sr-only">Minimum price</label> |
| 217 |
{# The applied bound is rendered back in: hx-include resends |
| 218 |
these on every request, so a blank input would silently |
| 219 |
clear the filter on the next interaction. #} |
| 220 |
<input type="number" id="min-price" name="min_price" placeholder="0" min="0" |
| 221 |
value="{{ sidebar.current_min_price }}" |
| 222 |
class="discover-filter" |
| 223 |
aria-label="Minimum price" |
| 224 |
hx-get="/discover/results" |
| 225 |
hx-trigger="change delay:500ms" |
| 226 |
hx-target="#results-container" |
| 227 |
hx-indicator="#search-spinner" |
| 228 |
hx-include=".discover-filter"> |
| 229 |
<span aria-hidden="true">to</span> |
| 230 |
<label for="max-price" class="sr-only">Maximum price</label> |
| 231 |
<input type="number" id="max-price" name="max_price" placeholder="Any" |
| 232 |
value="{{ sidebar.current_max_price }}" |
| 233 |
class="discover-filter" |
| 234 |
aria-label="Maximum price" |
| 235 |
hx-get="/discover/results" |
| 236 |
hx-trigger="change delay:500ms" |
| 237 |
hx-target="#results-container" |
| 238 |
hx-indicator="#search-spinner" |
| 239 |
hx-include=".discover-filter"> |
| 240 |
</div> |
| 241 |
{# Buckets are links, not form state, so they cannot disagree |
| 242 |
with the number inputs above and they work without JS. #} |
| 243 |
<ul class="filter-list price-buckets" aria-label="Price ranges"> |
| 244 |
{% for pb in sidebar.price_buckets %} |
| 245 |
<li class="filter-item{% if pb.active %} is-selected{% endif %}"> |
| 246 |
<a class="price-bucket" href="{{ pb.url }}" |
| 247 |
{% if pb.active %}aria-current="true"{% endif %}> |
| 248 |
<span>{{ pb.label }}</span> |
| 249 |
<span class="count" aria-label="{{ pb.count }} items">{{ pb.count }}</span> |
| 250 |
</a> |
| 251 |
</li> |
| 252 |
{% endfor %} |
| 253 |
</ul> |
| 254 |
</div> |
| 255 |
|
| 256 |
{% if !sidebar.ai_tier_filters.is_empty() %} |
| 257 |
<div class="filter-section"> |
| 258 |
<fieldset class="filter-fieldset"> |
| 259 |
{# Radios, not checkboxes: the three tiers are nested |
| 260 |
ranges, so OR-ing any two only widens to the looser. #} |
| 261 |
<legend class="filter-title">AI Disclosure</legend> |
| 262 |
<ul class="filter-list"> |
| 263 |
{% for af in sidebar.ai_tier_filters %} |
| 264 |
<li class="filter-item{% if af.active %} is-selected{% endif %}"> |
| 265 |
<label class="filter-check"> |
| 266 |
<input type="radio" name="ai_tier" value="{{ af.value }}" |
| 267 |
id="aitier-{{ af.value }}" |
| 268 |
class="discover-filter" |
| 269 |
{% if af.active %}checked{% endif %} |
| 270 |
hx-get="/discover/results" |
| 271 |
hx-target="#results-container" |
| 272 |
hx-indicator="#search-spinner" |
| 273 |
hx-include=".discover-filter" |
| 274 |
hx-trigger="change"> |
| 275 |
<span>{{ af.name }}</span> |
| 276 |
<span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span> |
| 277 |
</label> |
| 278 |
</li> |
| 279 |
{% endfor %} |
| 280 |
</ul> |
| 281 |
</fieldset> |
| 282 |
</div> |
| 283 |
{% endif %} |
| 284 |
</div> |
| 285 |
{% endif %} |
| 286 |
</aside> |
| 287 |
|