| 1 |
{# The sidebar rides along on every results swap, out-of-band, so its counts |
| 2 |
and drill-down always describe the results below. Without this the sidebar |
| 3 |
is stale from the first filter click until a full page load, which is fatal |
| 4 |
for faceted browsing: the numbers are what the user steers by. |
| 5 |
|
| 6 |
htmx restores focus after a swap to an element with the same id, so every |
| 7 |
control in the sidebar carries a stable one and keyboard position survives. |
| 8 |
The typeahead input additionally uses hx-preserve, since its value is user |
| 9 |
state the server does not render. |
| 10 |
|
| 11 |
Guarded on `oob_sidebar`: the full page includes this same partial inside |
| 12 |
#results-container, and rendering the sidebar there too would emit it twice |
| 13 |
with duplicate ids. Only the standalone /discover/results response sets it. #} |
| 14 |
{# #total-count sits in the page form, outside #results-container, so a plain |
| 15 |
swap leaves it reading the previous filter's total. It said "13 items" beside |
| 16 |
a list showing 3. #} |
| 17 |
{# Search results say how well they matched, in two places. |
| 18 |
|
| 19 |
.results-tier-break is the one heading, and it sits exactly where the claim |
| 20 |
it makes becomes true: above it, every row holds at least one word that was |
| 21 |
typed; below it, every row holds none and is here on character overlap alone. |
| 22 |
The query orders the tiers and flags the first row of the lower one. A page |
| 23 |
made entirely of near matches carries the heading at the top, which is the |
| 24 |
honest rendering under pagination. No heading when not searching, and none |
| 25 |
for 1-2 character searches, which are substring-only and have no boundary. |
| 26 |
|
| 27 |
.row-match-note is the per-row half: "Matched 3 of 5 words" on a row holding |
| 28 |
some but not all of them. It cannot be a heading, because coverage varies row |
| 29 |
to row and one page can hold a 4-of-5 beside a 1-of-5. Rows with every word |
| 30 |
say nothing, since that is the unremarkable case; rows with none say nothing |
| 31 |
either, because the heading above them already did. |
| 32 |
|
| 33 |
Grid view needs `grid-column: 1 / -1` on .results-tier-break to span the row. |
| 34 |
Discover has no CSS yet, so these are class hooks like the rest of the sidebar. #} |
| 35 |
{% if oob_sidebar %} |
| 36 |
<span class="table-meta" id="total-count" hx-swap-oob="true">{{ count_label }}</span> |
| 37 |
{% endif %} |
| 38 |
{% if oob_sidebar && mode == "items" %} |
| 39 |
<div hx-swap-oob="outerHTML:#discover-sidebar"> |
| 40 |
{% include "partials/discover_sidebar.html" %} |
| 41 |
</div> |
| 42 |
{% endif %} |
| 43 |
|
| 44 |
{# The region a filter change replaces, whole. Its id is here rather than on a |
| 45 |
wrapper in the page, so the answer to /discover/results IS the region and an |
| 46 |
outer swap keeps the element every control targets. The out-of-band elements |
| 47 |
above stay outside it: htmx only honours hx-swap-oob at the top level of a |
| 48 |
response. #} |
| 49 |
<div id="results-container"> |
| 50 |
<div class="results-container results-list" id="results-container-inner"> |
| 51 |
|
| 52 |
<div class="results-table well" id="results-table"> |
| 53 |
{% if mode == "projects" %} |
| 54 |
{% for project in projects %} |
| 55 |
{% if project.starts_fuzzy_block %} |
| 56 |
<div class="results-tier-break"> |
| 57 |
<span class="results-tier-label">Near matches</span> |
| 58 |
<span class="results-tier-hint">None of your words are in these. The spelling is close.</span> |
| 59 |
</div> |
| 60 |
{% endif %} |
| 61 |
<a href="/p/{{ project.slug }}" class="table-row project-row"> |
| 62 |
<span class="row-info"><span class="row-name">{{ project.title }}</span><span class="row-creator">{{ project.creator }}</span>{% if project.match_label.is_some() %}<span class="row-match-note">{{ project.match_label.as_deref().unwrap_or("") }}</span>{% endif %}</span> |
| 63 |
<span class="row-category">{{ project.category_name.as_deref().unwrap_or(&project.project_type) }}</span> |
| 64 |
<span class="row-items">{{ project.item_count }}</span> |
| 65 |
<span class="row-date">{{ project.date }}</span> |
| 66 |
</a> |
| 67 |
{% endfor %} |
| 68 |
{% if projects.is_empty() %} |
| 69 |
<div class="empty-state"> |
| 70 |
{% if is_search %} |
| 71 |
<p>Nothing matched your search.</p> |
| 72 |
<p class="empty-state-hint">Try fewer words, or check the spelling. You can also <a href="/discover">clear all filters</a>.</p> |
| 73 |
{% else %} |
| 74 |
<p>No projects found matching your filters.</p> |
| 75 |
<p class="empty-state-hint">Try <a href="/discover">clearing all filters</a>.</p> |
| 76 |
{% endif %} |
| 77 |
</div> |
| 78 |
{% endif %} |
| 79 |
{% else %} |
| 80 |
{% for item in items %} |
| 81 |
{% if item.starts_fuzzy_block %} |
| 82 |
<div class="results-tier-break"> |
| 83 |
<span class="results-tier-label">Near matches</span> |
| 84 |
<span class="results-tier-hint">None of your words are in these. The spelling is close.</span> |
| 85 |
</div> |
| 86 |
{% endif %} |
| 87 |
<div class="table-row table-row-item"> |
| 88 |
<a href="{% if item.is_free %}/i/{{ item.id }}{% else %}/purchase/{{ item.id }}{% endif %}" class="table-row-link"> |
| 89 |
<span class="row-type">{{ item.item_type }}</span> |
| 90 |
<span class="row-info"><span class="row-name">{{ item.name }}</span><span class="row-creator">{{ item.project }} · {{ item.creator }}</span>{% if item.match_label.is_some() %}<span class="row-match-note">{{ item.match_label.as_deref().unwrap_or("") }}</span>{% endif %}</span> |
| 91 |
<span class="row-category">{{ item.primary_tag }}</span> |
| 92 |
<span class="row-price">{{ item.price }}</span> |
| 93 |
<span class="row-date">{{ item.date }}</span> |
| 94 |
<span class="discover-row-ai-tier">{{ item.ai_tier }}</span> |
| 95 |
</a> |
| 96 |
{% if is_authenticated %} |
| 97 |
<button class="row-save collection-picker-anchor" data-collection-trigger data-item-id="{{ item.id }}" |
| 98 |
data-action="onOpenCollectionPicker" data-stop title="Save to collection" aria-label="Save to collection">+</button> |
| 99 |
{% endif %} |
| 100 |
</div> |
| 101 |
{% endfor %} |
| 102 |
{% if items.is_empty() %} |
| 103 |
<div class="empty-state"> |
| 104 |
{% if is_search %} |
| 105 |
<p>Nothing matched your search.</p> |
| 106 |
<p class="empty-state-hint">Try fewer words, or check the spelling. You can also <a href="/discover">clear all filters</a>.</p> |
| 107 |
{% else %} |
| 108 |
<p>No items found matching your filters.</p> |
| 109 |
<p class="empty-state-hint">Try <a href="/discover">clearing all filters</a>.</p> |
| 110 |
{% endif %} |
| 111 |
</div> |
| 112 |
{% endif %} |
| 113 |
{% endif %} |
| 114 |
</div> |
| 115 |
|
| 116 |
|
| 117 |
<div class="results-grid" id="results-grid"> |
| 118 |
{% if mode == "projects" %} |
| 119 |
{% for project in projects %} |
| 120 |
{% if project.starts_fuzzy_block %} |
| 121 |
<div class="results-tier-break"> |
| 122 |
<span class="results-tier-label">Near matches</span> |
| 123 |
<span class="results-tier-hint">None of your words are in these. The spelling is close.</span> |
| 124 |
</div> |
| 125 |
{% endif %} |
| 126 |
<a href="/p/{{ project.slug }}" class="grid-card"> |
| 127 |
<div class="grid-card-content"> |
| 128 |
<div class="grid-card-title">{{ project.title }}</div> |
| 129 |
<div class="grid-card-meta">{{ project.creator }}</div> |
| 130 |
{% if project.match_label.is_some() %}<span class="row-match-note">{{ project.match_label.as_deref().unwrap_or("") }}</span>{% endif %} |
| 131 |
<div class="grid-card-footer"> |
| 132 |
<span class="grid-card-category">{{ project.category_name.as_deref().unwrap_or(&project.project_type) }}</span> |
| 133 |
<span class="grid-card-stat">{{ project.item_count }} items</span> |
| 134 |
</div> |
| 135 |
</div> |
| 136 |
</a> |
| 137 |
{% endfor %} |
| 138 |
{% if projects.is_empty() %} |
| 139 |
<div class="empty-state"> |
| 140 |
{% if is_search %} |
| 141 |
<p>Nothing matched your search.</p> |
| 142 |
<p class="empty-state-hint">Try fewer words, or check the spelling. You can also <a href="/discover">clear all filters</a>.</p> |
| 143 |
{% else %} |
| 144 |
<p>No projects found matching your filters.</p> |
| 145 |
<p class="empty-state-hint">Try <a href="/discover">clearing all filters</a>.</p> |
| 146 |
{% endif %} |
| 147 |
</div> |
| 148 |
{% endif %} |
| 149 |
{% else %} |
| 150 |
{% for item in items %} |
| 151 |
{% if item.starts_fuzzy_block %} |
| 152 |
<div class="results-tier-break"> |
| 153 |
<span class="results-tier-label">Near matches</span> |
| 154 |
<span class="results-tier-hint">None of your words are in these. The spelling is close.</span> |
| 155 |
</div> |
| 156 |
{% endif %} |
| 157 |
<div class="grid-card discover-grid-card"> |
| 158 |
{% if is_authenticated %} |
| 159 |
<button class="grid-card-save collection-picker-anchor" data-collection-trigger data-item-id="{{ item.id }}" |
| 160 |
data-action="onOpenCollectionPicker" data-stop title="Save to collection" aria-label="Save to collection">+</button> |
| 161 |
{% endif %} |
| 162 |
<a href="{% if item.is_free %}/i/{{ item.id }}{% else %}/purchase/{{ item.id }}{% endif %}" class="discover-card-link"> |
| 163 |
<div class="grid-card-thumbnail">{{ item.item_type }}</div> |
| 164 |
<div class="grid-card-content"> |
| 165 |
<div class="grid-card-title">{{ item.name }}</div> |
| 166 |
<div class="grid-card-meta">{{ item.project }} · {{ item.creator }}</div> |
| 167 |
{% if item.match_label.is_some() %}<span class="row-match-note">{{ item.match_label.as_deref().unwrap_or("") }}</span>{% endif %} |
| 168 |
<div class="grid-card-footer"> |
| 169 |
<span class="grid-card-category">{{ item.primary_tag }}</span> |
| 170 |
<span class="grid-card-price">{{ item.price }}</span> |
| 171 |
<span class="discover-row-ai-tier">{{ item.ai_tier }}</span> |
| 172 |
</div> |
| 173 |
</div> |
| 174 |
</a> |
| 175 |
</div> |
| 176 |
{% endfor %} |
| 177 |
{% if items.is_empty() %} |
| 178 |
<div class="empty-state"> |
| 179 |
{% if is_search %} |
| 180 |
<p>Nothing matched your search.</p> |
| 181 |
<p class="empty-state-hint">Try fewer words, or check the spelling. You can also <a href="/discover">clear all filters</a>.</p> |
| 182 |
{% else %} |
| 183 |
<p>No items found matching your filters.</p> |
| 184 |
<p class="empty-state-hint">Try <a href="/discover">clearing all filters</a>.</p> |
| 185 |
{% endif %} |
| 186 |
</div> |
| 187 |
{% endif %} |
| 188 |
{% endif %} |
| 189 |
</div> |
| 190 |
</div> |
| 191 |
|
| 192 |
<div class="table-footer"> |
| 193 |
<span class="page-info">Showing {{ showing_start }}-{{ showing_end }} of {{ total_items }}</span> |
| 194 |
<div class="pagination"> |
| 195 |
{% if total_pages > 0 %} |
| 196 |
<button {% if current_page <= 1 %}disabled{% endif %} |
| 197 |
hx-get="/discover/results" |
| 198 |
hx-target="#results-container" hx-swap="outerHTML" |
| 199 |
hx-include=".discover-filter, #q" |
| 200 |
hx-vals='{"page": {{ current_page - 1 }}}' |
| 201 |
{% if current_page <= 1 %}hx-disable="this"{% endif %}>Prev</button> |
| 202 |
{% for p in pagination_range %} |
| 203 |
<button {% if *p == current_page %}class="is-selected"{% endif %} |
| 204 |
hx-get="/discover/results" |
| 205 |
hx-target="#results-container" hx-swap="outerHTML" |
| 206 |
hx-include=".discover-filter, #q" |
| 207 |
hx-vals='{"page": {{ p }}}'>{{ p }}</button> |
| 208 |
{% endfor %} |
| 209 |
{% if total_pages > 5 %} |
| 210 |
<button disabled>...</button> |
| 211 |
<button hx-get="/discover/results" |
| 212 |
hx-target="#results-container" hx-swap="outerHTML" |
| 213 |
hx-include=".discover-filter, #q" |
| 214 |
hx-vals='{"page": {{ total_pages }}}'>{{ total_pages }}</button> |
| 215 |
{% endif %} |
| 216 |
<button {% if current_page >= total_pages || total_pages == 0 %}disabled{% endif %} |
| 217 |
hx-get="/discover/results" |
| 218 |
hx-target="#results-container" hx-swap="outerHTML" |
| 219 |
hx-include=".discover-filter, #q" |
| 220 |
hx-vals='{"page": {{ current_page + 1 }}}' |
| 221 |
{% if current_page >= total_pages || total_pages == 0 %}hx-disable="this"{% endif %}>Next</button> |
| 222 |
{% endif %} |
| 223 |
</div> |
| 224 |
</div> |
| 225 |
</div> |
| 226 |
|