Skip to main content

max / makenotwork

17.9 KB · 292 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" hx-swap="outerHTML"
43 hx-indicator="#search-spinner"
44 hx-include=".discover-filter, #q"
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" hx-swap="outerHTML"
63 hx-indicator="#search-spinner"
64 hx-include=".discover-filter, #q">
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
101 Described (N8): the box, the list it owns, the route it asks,
102 the wait, the floor, the filters that ride along and what
103 picking a row does all come out of crate::quasi. What stood
104 here was the box and an empty list pointed at each other by
105 id, with the rest in page-discover.js. #}
106 <div class="tag-combobox">
107 {{ crate::quasi::discover_typeahead::tag_box()|safe }}
108 </div>
109
110 {# Drill-down. `browse` moves the cursor, `tag` selects; a
111 category navigates, a depth-3+ leaf filters. #}
112 <nav class="tag-drill" aria-labelledby="tag-label">
113 {% if !sidebar.tag_crumbs.is_empty() %}
114 <ol class="tag-crumbs">
115 <li><a href="{{ sidebar.browse_url_root }}">All</a></li>
116 {% for crumb in sidebar.tag_crumbs %}
117 <li><a href="{{ sidebar.browse_url_prefix }}{{ crumb.slug }}">{{ crumb.label }}</a></li>
118 {% endfor %}
119 </ol>
120 {% endif %}
121 <ul class="filter-list">
122 {% for row in sidebar.tag_drill %}
123 <li class="filter-item{% if row.selected %} is-selected{% endif %}{% if row.count == 0 %} is-empty{% endif %}">
124 {% if row.assignable %}
125 {# A real checkbox: multi-select is the point, and it
126 is keyboard-operable without any hx-trigger work. #}
127 <label class="tag-drill-select">
128 <input type="checkbox" name="tag" value="{{ row.slug }}"
129 id="tagsel-{{ row.slug }}"
130 class="discover-filter" data-facet="tag"
131 {% if row.selected %}checked{% endif %}
132 hx-get="/discover/results"
133 hx-target="#results-container" hx-swap="outerHTML"
134 hx-indicator="#search-spinner"
135 hx-include=".discover-filter, #q"
136 hx-trigger="change">
137 <span>{{ row.label }}</span>
138 <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>
139 </label>
140 {% endif %}
141 {% if row.has_children %}
142 <a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}"
143 aria-label="Browse inside {{ row.label }}">
144 {% if !row.assignable %}<span>{{ row.label }}</span>
145 <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %}
146 <span class="tag-drill-chevron" aria-hidden="true">&rsaquo;</span>
147 </a>
148 {% endif %}
149 {# Follow is a SIBLING of the label, never inside it:
150 a control nested in a label would toggle the
151 checkbox when clicked. Offered on EVERY rung, not
152 just assignable leaves: following is hierarchical,
153 so a follow on a branch matches its whole subtree
154 (see db::follows::FOLLOWED_ITEM_IDS_CTE). It is the
155 last child so it right-aligns identically whether
156 the row leads with a checkbox label or a drill-in
157 link, both of which take flex: 1. #}
158 {% if sidebar.viewer_authenticated %}
159 {% if row.following %}
160 <button class="tag-follow-btn is-selected"
161 hx-delete="/api/follow/tag/{{ row.tag_id }}"
162 hx-swap="outerHTML"
163 aria-label="Unfollow {{ row.label }}"
164 data-action="noop" data-stop>Following</button>
165 {% else %}
166 <button class="tag-follow-btn"
167 hx-post="/api/follow/tag/{{ row.tag_id }}"
168 hx-swap="outerHTML"
169 aria-label="Follow {{ row.label }}"
170 data-action="noop" data-stop>Follow</button>
171 {% endif %}
172 {% endif %}
173 </li>
174 {% endfor %}
175 </ul>
176 </nav>
177 </div>
178
179 {# ---- Refine ----------------------------------------------------
180 Everything below the spine narrows what the tags selected. Same
181 multi-select treatment, deliberately subordinate. Every control
182 here is a real form input: multi-select needs one, and it makes
183 the whole sidebar keyboard-operable without any hx-trigger work
184 (the .filter-btn elements these replace were not). ---- #}
185 <div class="filter-refine">
186 <div class="filter-section">
187 <fieldset class="filter-fieldset">
188 <legend class="filter-title">Type</legend>
189 <ul class="filter-list">
190 {% for tf in sidebar.type_filters %}
191 {% if !tf.value.is_empty() %}
192 <li class="filter-item{% if tf.active %} is-selected{% endif %}">
193 <label class="filter-check">
194 <input type="checkbox" name="item_type" value="{{ tf.value }}"
195 id="typesel-{{ tf.value }}"
196 class="discover-filter" data-facet="item_type"
197 {% if tf.active %}checked{% endif %}
198 {% if tf.count == 0 && !tf.active %}disabled{% endif %}
199 hx-get="/discover/results"
200 hx-target="#results-container" hx-swap="outerHTML"
201 hx-indicator="#search-spinner"
202 hx-include=".discover-filter, #q"
203 hx-trigger="change">
204 <span>{{ tf.name }}</span>
205 <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span>
206 </label>
207 </li>
208 {% endif %}
209 {% endfor %}
210 </ul>
211 </fieldset>
212 </div>
213
214 <div class="filter-section">
215 <div class="filter-title" id="price-label">Price ($)</div>
216 <div class="price-inputs" role="group" aria-labelledby="price-label">
217 <label for="min-price" class="sr-only">Minimum price in dollars</label>
218 {# The applied bound is rendered back in: hx-include resends
219 these on every request, so a blank input would silently
220 clear the filter on the next interaction.
221
222 Dollars, matching the unit in the URL. These read cents
223 until 2026-08-04, so typing 20 filtered for items under
224 twenty cents with nothing on screen to say so. #}
225 <input type="number" id="min-price" name="min_price" placeholder="0" min="0" step="0.01"
226 value="{{ sidebar.current_min_price }}"
227 class="discover-filter"
228 aria-label="Minimum price in dollars"
229 hx-get="/discover/results"
230 hx-trigger="change delay:500ms"
231 hx-target="#results-container" hx-swap="outerHTML"
232 hx-indicator="#search-spinner"
233 hx-include=".discover-filter, #q">
234 <span aria-hidden="true">to</span>
235 <label for="max-price" class="sr-only">Maximum price in dollars</label>
236 <input type="number" id="max-price" name="max_price" placeholder="Any" min="0" step="0.01"
237 value="{{ sidebar.current_max_price }}"
238 class="discover-filter"
239 aria-label="Maximum price in dollars"
240 hx-get="/discover/results"
241 hx-trigger="change delay:500ms"
242 hx-target="#results-container" hx-swap="outerHTML"
243 hx-indicator="#search-spinner"
244 hx-include=".discover-filter, #q">
245 </div>
246 {# Buckets are links, not form state, so they cannot disagree
247 with the number inputs above and they work without JS. #}
248 <ul class="filter-list price-buckets" aria-label="Price ranges">
249 {% for pb in sidebar.price_buckets %}
250 <li class="filter-item{% if pb.active %} is-selected{% endif %}">
251 <a class="price-bucket" href="{{ pb.url }}"
252 {% if pb.active %}aria-current="true"{% endif %}>
253 <span>{{ pb.label }}</span>
254 <span class="count" aria-label="{{ pb.count }} items">{{ pb.count }}</span>
255 </a>
256 </li>
257 {% endfor %}
258 </ul>
259 </div>
260
261 {% if !sidebar.ai_tier_filters.is_empty() %}
262 <div class="filter-section">
263 <fieldset class="filter-fieldset">
264 {# Radios, not checkboxes: the three tiers are nested
265 ranges, so OR-ing any two only widens to the looser. #}
266 <legend class="filter-title">AI Disclosure</legend>
267 <ul class="filter-list">
268 {% for af in sidebar.ai_tier_filters %}
269 <li class="filter-item{% if af.active %} is-selected{% endif %}">
270 <label class="filter-check">
271 <input type="radio" name="ai_tier" value="{{ af.value }}"
272 id="aitier-{{ af.value }}"
273 class="discover-filter"
274 {% if af.active %}checked{% endif %}
275 hx-get="/discover/results"
276 hx-target="#results-container" hx-swap="outerHTML"
277 hx-indicator="#search-spinner"
278 hx-include=".discover-filter, #q"
279 hx-trigger="change">
280 <span>{{ af.name }}</span>
281 <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span>
282 </label>
283 </li>
284 {% endfor %}
285 </ul>
286 </fieldset>
287 </div>
288 {% endif %}
289 </div>
290 {% endif %}
291 </aside>
292