Skip to main content

max / makenotwork

17.8 KB · 291 lines History Blame Raw
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 }}">&times;</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">&rsaquo;</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 in dollars</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
221 Dollars, matching the unit in the URL. These read cents
222 until 2026-08-04, so typing 20 filtered for items under
223 twenty cents with nothing on screen to say so. #}
224 <input type="number" id="min-price" name="min_price" placeholder="0" min="0" step="0.01"
225 value="{{ sidebar.current_min_price }}"
226 class="discover-filter"
227 aria-label="Minimum price in dollars"
228 hx-get="/discover/results"
229 hx-trigger="change delay:500ms"
230 hx-target="#results-container"
231 hx-indicator="#search-spinner"
232 hx-include=".discover-filter">
233 <span aria-hidden="true">to</span>
234 <label for="max-price" class="sr-only">Maximum price in dollars</label>
235 <input type="number" id="max-price" name="max_price" placeholder="Any" min="0" step="0.01"
236 value="{{ sidebar.current_max_price }}"
237 class="discover-filter"
238 aria-label="Maximum price in dollars"
239 hx-get="/discover/results"
240 hx-trigger="change delay:500ms"
241 hx-target="#results-container"
242 hx-indicator="#search-spinner"
243 hx-include=".discover-filter">
244 </div>
245 {# Buckets are links, not form state, so they cannot disagree
246 with the number inputs above and they work without JS. #}
247 <ul class="filter-list price-buckets" aria-label="Price ranges">
248 {% for pb in sidebar.price_buckets %}
249 <li class="filter-item{% if pb.active %} is-selected{% endif %}">
250 <a class="price-bucket" href="{{ pb.url }}"
251 {% if pb.active %}aria-current="true"{% endif %}>
252 <span>{{ pb.label }}</span>
253 <span class="count" aria-label="{{ pb.count }} items">{{ pb.count }}</span>
254 </a>
255 </li>
256 {% endfor %}
257 </ul>
258 </div>
259
260 {% if !sidebar.ai_tier_filters.is_empty() %}
261 <div class="filter-section">
262 <fieldset class="filter-fieldset">
263 {# Radios, not checkboxes: the three tiers are nested
264 ranges, so OR-ing any two only widens to the looser. #}
265 <legend class="filter-title">AI Disclosure</legend>
266 <ul class="filter-list">
267 {% for af in sidebar.ai_tier_filters %}
268 <li class="filter-item{% if af.active %} is-selected{% endif %}">
269 <label class="filter-check">
270 <input type="radio" name="ai_tier" value="{{ af.value }}"
271 id="aitier-{{ af.value }}"
272 class="discover-filter"
273 {% if af.active %}checked{% endif %}
274 hx-get="/discover/results"
275 hx-target="#results-container"
276 hx-indicator="#search-spinner"
277 hx-include=".discover-filter"
278 hx-trigger="change">
279 <span>{{ af.name }}</span>
280 <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span>
281 </label>
282 </li>
283 {% endfor %}
284 </ul>
285 </fieldset>
286 </div>
287 {% endif %}
288 </div>
289 {% endif %}
290 </aside>
291