Skip to main content

max / makenotwork

18.7 KB · 367 lines History Blame Raw
1 {% extends "base.html" %}
2
3 {% block title %}Discover - Makenotwork{% endblock %}
4 {% block body_attrs %} class="padded-page"{% endblock %}
5
6 {% block content %}
7 {% include "partials/site_header.html" %}
8
9 <h1 class="page-title">Discover</h1>
10
11 <div class="discover-layout{% if mode == "projects" && category_filters.is_empty() %} no-sidebar{% endif %}">
12 <aside class="discover-sidebar" aria-label="Filters">
13 {% if mode == "projects" %}
14 <div class="filter-section">
15 <div class="filter-title" id="category-label">Category</div>
16 <ul class="filter-list" role="listbox" aria-labelledby="category-label">
17 {% for cf in category_filters %}
18 <li class="filter-item{% if cf.active %} is-selected{% endif %}"
19 role="option"
20 aria-selected="{% if cf.active %}true{% else %}false{% endif %}"
21 tabindex="0"
22 hx-get="/discover/results"
23 hx-target="#results-container"
24 hx-indicator="#search-spinner"
25 hx-include=".discover-filter"
26 hx-vals='{"category": "{{ cf.value }}"}'
27 >
28 <span>{{ cf.name }}</span> <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span>
29 </li>
30 {% endfor %}
31 </ul>
32 </div>
33 <div class="filter-section">
34 <label class="filter-checkbox discover-filter-checkbox">
35 <input type="checkbox" id="has-source-input" name="has_source"
36 class="discover-filter"
37 value="1"
38 {% if has_source %}checked{% endif %}
39 hx-get="/discover/results"
40 hx-trigger="change"
41 hx-target="#results-container"
42 hx-indicator="#search-spinner"
43 hx-include=".discover-filter">
44 Has source code
45 </label>
46 </div>
47 {% endif %}
48 {% if mode == "items" %}
49 <div class="filter-section">
50 <div class="filter-title" id="type-label">Type</div>
51 <ul class="filter-list" role="listbox" aria-labelledby="type-label">
52 {% for tf in type_filters %}
53 <li class="filter-item{% if tf.active %} is-selected{% endif %}"
54 role="option"
55 aria-selected="{% if tf.active %}true{% else %}false{% endif %}"
56 tabindex="0"
57 hx-get="/discover/results"
58 hx-target="#results-container"
59 hx-indicator="#search-spinner"
60 hx-include=".discover-filter"
61 hx-vals='{"item_type": "{{ tf.value }}"}'
62 >
63 <span>{{ tf.name }}</span> <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span>
64 </li>
65 {% endfor %}
66 </ul>
67 </div>
68
69 <div class="filter-section">
70 <div class="filter-title" id="tag-label">Tags <a href="/discover/tags" class="filter-browse-link">Browse all</a></div>
71 <ul class="filter-list" role="listbox" aria-labelledby="tag-label">
72 {% for tg in tag_filters %}
73 <li class="filter-item{% if tg.active %} is-selected{% endif %}"
74 role="option"
75 aria-selected="{% if tg.active %}true{% else %}false{% endif %}"
76 tabindex="0"
77 hx-get="/discover/results"
78 hx-target="#results-container"
79 hx-indicator="#search-spinner"
80 hx-include=".discover-filter"
81 hx-vals='{"tag": "{{ tg.value }}"}'
82 >
83 <span>{{ tg.name }}</span>
84 <span class="filter-item-right">
85 <span class="count" aria-label="{{ tg.count }} items">{{ tg.count }}</span>
86 {% if session_user.is_some() && !tg.id.is_empty() %}
87 {% if tg.following %}
88 <button class="tag-follow-btn is-selected" hx-delete="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Unfollow" onclick="event.stopPropagation();">Following</button>
89 {% else %}
90 <button class="tag-follow-btn" hx-post="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Follow" onclick="event.stopPropagation();">Follow</button>
91 {% endif %}
92 {% endif %}
93 </span>
94 </li>
95 {% endfor %}
96 </ul>
97 </div>
98
99 <div class="filter-section">
100 <div class="filter-title" id="price-label">Price</div>
101 <div class="price-inputs" role="group" aria-labelledby="price-label">
102 <label for="min-price" class="sr-only">Minimum price</label>
103 <input type="number" id="min-price" name="min_price" placeholder="0" min="0"
104 class="discover-filter"
105 aria-label="Minimum price"
106 hx-get="/discover/results"
107 hx-trigger="change delay:500ms"
108 hx-target="#results-container"
109 hx-indicator="#search-spinner"
110 hx-include=".discover-filter">
111 <span aria-hidden="true">to</span>
112 <label for="max-price" class="sr-only">Maximum price</label>
113 <input type="number" id="max-price" name="max_price" placeholder="Any"
114 class="discover-filter"
115 aria-label="Maximum price"
116 hx-get="/discover/results"
117 hx-trigger="change delay:500ms"
118 hx-target="#results-container"
119 hx-indicator="#search-spinner"
120 hx-include=".discover-filter">
121 </div>
122 <ul class="filter-list price-distribution" role="list" aria-label="Price distribution">
123 {% for pf in price_filters %}
124 <li class="price-distribution-item">
125 {{ pf.label }}: {{ pf.count }}
126 </li>
127 {% endfor %}
128 </ul>
129 </div>
130
131 {% if !ai_tier_filters.is_empty() %}
132 <div class="filter-section">
133 <div class="filter-title" id="ai-tier-label">AI Disclosure</div>
134 <ul class="filter-list" role="listbox" aria-labelledby="ai-tier-label">
135 {% for af in ai_tier_filters %}
136 <li class="filter-item{% if af.active %} is-selected{% endif %}"
137 role="option"
138 aria-selected="{% if af.active %}true{% else %}false{% endif %}"
139 tabindex="0"
140 hx-get="/discover/results"
141 hx-target="#results-container"
142 hx-indicator="#search-spinner"
143 hx-include=".discover-filter"
144 hx-vals='{"ai_tier": "{{ af.value }}"}'>
145 <span>{{ af.name }}</span> <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span>
146 </li>
147 {% endfor %}
148 </ul>
149 </div>
150 {% endif %}
151 {% endif %}
152 </aside>
153
154 <button type="button" class="discover-filter-toggle" onclick="document.querySelector('.discover-sidebar').classList.toggle('show'); this.classList.toggle('active');" aria-label="Toggle filters">
155 Filters{% if active_filter_count > 0 %} <span class="filter-count">{{ active_filter_count }}</span>{% endif %}
156 </button>
157
158 <main class="discover-main">
159 <div class="mode-toggle">
160 <button class="toggle-btn{% if mode == "items" %} is-selected{% endif %}"
161 hx-get="/discover"
162 hx-target="body"
163 hx-push-url="true"
164 hx-vals='{"mode": "items"}'>Items</button>
165 <button class="toggle-btn{% if mode == "projects" %} is-selected{% endif %}"
166 hx-get="/discover"
167 hx-target="body"
168 hx-push-url="true"
169 hx-vals='{"mode": "projects"}'>Projects</button>
170 <div class="view-controls">
171 <button type="button" class="view-btn" data-view="list" title="List view">|||</button>
172 <button type="button" class="view-btn is-selected" data-view="grid" title="Grid view">:::</button>
173 </div>
174 </div>
175
176 <form id="discover-form" action="/discover" method="get">
177 <input type="hidden" id="mode-input" name="mode" value="{{ mode }}" class="discover-filter">
178 <div class="table-controls">
179 <label for="search-input" class="sr-only">Search {% if mode == "projects" %}projects{% else %}items{% endif %}</label>
180 <div class="search-wrapper">
181 <input type="text"
182 id="search-input"
183 class="search-field discover-filter"
184 placeholder="Search {% if mode == "projects" %}projects{% else %}items{% endif %}..."
185 name="q"
186 value="{{ search_query }}"
187 autocomplete="off"
188 aria-label="Search {% if mode == "projects" %}projects{% else %}items{% endif %}"
189 hx-get="/discover/results"
190 hx-trigger="input changed delay:150ms, search"
191 hx-target="#results-container"
192 hx-indicator="#search-spinner"
193 hx-include=".discover-filter">
194 <div id="search-suggestions" class="search-suggestions"></div>
195 </div>
196 <span id="search-spinner" class="htmx-indicator" aria-live="polite">Searching...</span>
197 <span class="table-meta" id="total-count">{{ total_items }} {% if mode == "projects" %}projects{% else %}items{% endif %}</span>
198 <label for="sort-select" class="sr-only">Sort by</label>
199 <select class="sort-select discover-filter"
200 id="sort-select"
201 name="sort"
202 aria-label="Sort results by"
203 hx-get="/discover/results"
204 hx-trigger="change"
205 hx-target="#results-container"
206 hx-indicator="#search-spinner"
207 hx-include=".discover-filter">
208 {% if mode == "projects" %}
209 <option value="newest"{% if sort_by == "newest" || sort_by == "" %} selected{% endif %}>Newest</option>
210 <option value="most_sold"{% if sort_by == "most_sold" %} selected{% endif %}>Most items</option>
211 {% else %}
212 <option value="most_sold"{% if sort_by == "most_sold" %} selected{% endif %}>Most sold</option>
213 <option value="newest"{% if sort_by == "newest" || sort_by == "" %} selected{% endif %}>Newest</option>
214 <option value="price_asc"{% if sort_by == "price_asc" %} selected{% endif %}>Price asc</option>
215 <option value="price_desc"{% if sort_by == "price_desc" %} selected{% endif %}>Price desc</option>
216 {% endif %}
217 </select>
218 <input type="hidden" id="type-input" name="item_type" value="{{ current_type }}" class="discover-filter">
219 <input type="hidden" id="tag-input" name="tag" value="{{ current_tag }}" class="discover-filter">
220 <input type="hidden" id="category-input" name="category" value="{{ current_category }}" class="discover-filter">
221 <input type="hidden" id="ai-tier-input" name="ai_tier" value="{{ current_ai_tier }}" class="discover-filter">
222 </div>
223 </form>
224
225 {% if mode == "projects" %}
226 <div class="table-header projects-header">
227 <span>Name</span>
228 <span>Category</span>
229 <span class="col-right">Items</span>
230 <span class="col-right">Added</span>
231 </div>
232 {% else %}
233 <div class="table-header">
234 <span>Type</span>
235 <span>Name</span>
236 <span>Tag</span>
237 <span class="col-right">Price</span>
238 <span class="col-right">Added</span>
239 </div>
240 {% endif %}
241
242 <div id="results-container">
243 {% include "partials/discover_results.html" %}
244 </div>
245 </main>
246 </div>
247 {% endblock %}
248
249 {% block scripts %}
250 <script>
251 // Sync filter UI state on filter click: update active highlight and write
252 // the selected filter value into the hidden form input so subsequent
253 // search/sort submissions include it.
254 document.body.addEventListener('htmx:beforeRequest', function(evt) {
255 if (evt.detail.elt.classList.contains('filter-item')) {
256 // Only deactivate siblings in the same filter section
257 var section = evt.detail.elt.closest('.filter-list');
258 if (section) {
259 section.querySelectorAll('.filter-item').forEach(function(i) { i.classList.remove('is-selected'); });
260 }
261 evt.detail.elt.classList.add('is-selected');
262
263 var hxVals = JSON.parse(evt.detail.elt.getAttribute('hx-vals') || '{}');
264 if ('item_type' in hxVals) {
265 document.getElementById('type-input').value = hxVals.item_type || '';
266 }
267 if ('tag' in hxVals) {
268 document.getElementById('tag-input').value = hxVals.tag || '';
269 }
270 if ('category' in hxVals) {
271 document.getElementById('category-input').value = hxVals.category || '';
272 }
273 if ('label' in hxVals) {
274 document.getElementById('label-input').value = hxVals.label || '';
275 }
276 if ('ai_tier' in hxVals) {
277 document.getElementById('ai-tier-input').value = hxVals.ai_tier || '';
278 }
279 }
280 });
281
282 // View toggle (list vs grid) with localStorage persistence.
283 // Re-applied after HTMX swaps because new content replaces the container.
284 (function() {
285 function applyView(view) {
286 var container = document.getElementById('results-container-inner');
287 if (container) {
288 container.className = 'results-container results-' + view;
289 }
290 document.querySelectorAll('.view-btn').forEach(function(btn) {
291 btn.classList.toggle('is-selected', btn.dataset.view === view);
292 });
293 }
294
295 // Load saved preference on page load
296 document.addEventListener('DOMContentLoaded', function() {
297 var saved = safeStorageGet('discoverViewPref') || 'grid';
298 applyView(saved);
299 });
300
301 // Handle view button clicks
302 document.querySelectorAll('.view-btn').forEach(function(btn) {
303 btn.addEventListener('click', function() {
304 var view = btn.dataset.view;
305 applyView(view);
306 safeStorageSet('discoverViewPref', view);
307 });
308 });
309
310 // Re-apply view preference after HTMX swaps new content
311 document.body.addEventListener('htmx:afterSwap', function(evt) {
312 if (evt.detail.target.id === 'results-container') {
313 var saved = safeStorageGet('discoverViewPref') || 'grid';
314 applyView(saved);
315 }
316 });
317 })();
318
319 // Search suggestions autocomplete
320 (function() {
321 var input = document.getElementById('search-input');
322 var box = document.getElementById('search-suggestions');
323 var timer = null;
324 var selectedIdx = -1;
325
326 input.addEventListener('input', function() {
327 clearTimeout(timer);
328 var q = input.value.trim();
329 if (q.length < 2) { box.innerHTML = ''; box.style.display = 'none'; return; }
330 timer = setTimeout(function() {
331 fetch('/discover/suggestions?q=' + encodeURIComponent(q))
332 .then(function(r) { return r.json(); })
333 .then(function(items) {
334 if (items.length === 0) { box.innerHTML = ''; box.style.display = 'none'; return; }
335 selectedIdx = -1;
336 box.innerHTML = items.map(function(s, i) {
337 return '<a href="' + escapeHtml(s.url) + '" class="suggestion-item" data-idx="' + i + '">'
338 + '<span class="suggestion-label">' + escapeHtml(s.label) + '</span>'
339 + '<span class="suggestion-category">' + escapeHtml(s.category) + '</span></a>';
340 }).join('');
341 box.style.display = 'block';
342 });
343 }, 200);
344 });
345
346 input.addEventListener('keydown', function(e) {
347 var items = box.querySelectorAll('.suggestion-item');
348 if (!items.length) return;
349 if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, items.length - 1); updateHighlight(items); }
350 else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, -1); updateHighlight(items); }
351 else if (e.key === 'Enter' && selectedIdx >= 0) { e.preventDefault(); items[selectedIdx].click(); }
352 else if (e.key === 'Escape') { box.style.display = 'none'; }
353 });
354
355 function updateHighlight(items) {
356 items.forEach(function(el, i) { el.classList.toggle('highlighted', i === selectedIdx); });
357 }
358
359 function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
360
361 document.addEventListener('click', function(e) {
362 if (!box.contains(e.target) && e.target !== input) { box.style.display = 'none'; }
363 });
364 })();
365 </script>
366 {% endblock %}
367