Skip to main content

max / makenotwork

17.5 KB · 287 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</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