search for standard sites pub-search.waow.tech
search zig blog atproto
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

add tag filtering ui and increase tap timeout

- display top 15 tags, click to filter search results
- url params support: ?q=...&tag=...
- active tag filter indicator with clear button
- increase TAP_REPO_FETCH_TIMEOUT to 600s for large repos
- document entity types in readme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

zzstoatzz 37f519fe 27d7c7b4

+165 -29
+2
README.md
··· 21 21 GET /health # health check 22 22 ``` 23 23 24 + search returns three entity types: `article` (document in a publication), `looseleaf` (standalone document), `publication` (newsletter itself) 25 + 24 26 ## stack 25 27 26 28 - ~450 LOC of [Zig](https://ziglang.org) for the backend
+162 -29
site/index.html
··· 165 165 margin-top: 1.5rem; 166 166 text-align: center; 167 167 } 168 + 169 + .tags { 170 + margin-bottom: 1rem; 171 + } 172 + 173 + .tags-list { 174 + display: flex; 175 + flex-wrap: wrap; 176 + gap: 0.5rem; 177 + } 178 + 179 + .tag { 180 + font-size: 11px; 181 + padding: 3px 8px; 182 + background: #151515; 183 + border: 1px solid #252525; 184 + border-radius: 3px; 185 + cursor: pointer; 186 + color: #777; 187 + } 188 + 189 + .tag:hover { 190 + background: #1a1a1a; 191 + border-color: #333; 192 + color: #aaa; 193 + } 194 + 195 + .tag.active { 196 + background: rgba(27, 115, 64, 0.2); 197 + border-color: #1B7340; 198 + color: #2a9d5c; 199 + } 200 + 201 + .tag .count { 202 + color: #444; 203 + margin-left: 4px; 204 + } 205 + 206 + .active-filter { 207 + display: flex; 208 + align-items: center; 209 + gap: 0.5rem; 210 + margin-bottom: 1rem; 211 + padding: 0.5rem; 212 + background: rgba(27, 115, 64, 0.1); 213 + border: 1px solid #1B7340; 214 + border-radius: 4px; 215 + font-size: 12px; 216 + } 217 + 218 + .active-filter .clear { 219 + margin-left: auto; 220 + color: #666; 221 + cursor: pointer; 222 + } 223 + 224 + .active-filter .clear:hover { 225 + color: #c44; 226 + } 168 227 </style> 169 228 </head> 170 229 <body> ··· 176 235 <button id="search-btn">search</button> 177 236 </div> 178 237 238 + <div id="active-filter"></div> 239 + 240 + <div id="tags" class="tags"></div> 241 + 179 242 <div id="results" class="results"> 180 243 <div class="empty-state"> 181 244 <p>full-text search for leaflet documents</p> ··· 193 256 const searchBtn = document.getElementById('search-btn'); 194 257 const resultsDiv = document.getElementById('results'); 195 258 const statsDiv = document.getElementById('stats'); 259 + const tagsDiv = document.getElementById('tags'); 260 + const activeFilterDiv = document.getElementById('active-filter'); 261 + 262 + let currentTag = null; 263 + let allTags = []; 196 264 197 - async function search(query) { 198 - if (!query.trim()) return; 265 + async function search(query, tag = null) { 266 + if (!query.trim() && !tag) return; 199 267 200 268 searchBtn.disabled = true; 201 - const searchUrl = `${API_URL}/search?q=${encodeURIComponent(query)}`; 202 - resultsDiv.innerHTML = `<div class="status">searching...<br><code style="font-size:0.7rem;color:#525252">${searchUrl}</code></div>`; 269 + let searchUrl = `${API_URL}/search?q=${encodeURIComponent(query || '')}`; 270 + if (tag) searchUrl += `&tag=${encodeURIComponent(tag)}`; 271 + resultsDiv.innerHTML = `<div class="status">searching...</div>`; 203 272 204 273 try { 205 274 const res = await fetch(searchUrl); ··· 221 290 if (results.length === 0) { 222 291 resultsDiv.innerHTML = ` 223 292 <div class="empty-state"> 224 - <p>no results for "${escapeHtml(query)}"</p> 293 + <p>no results${query ? ` for "${escapeHtml(query)}"` : ''}${tag ? ` in #${escapeHtml(tag)}` : ''}</p> 225 294 <p>try different keywords</p> 226 - <p style="margin-top:1rem;font-size:0.7rem;color:#404040">query sent: <code>${searchUrl}</code></p> 227 - <p style="font-size:0.7rem;color:#404040">response: <code>${escapeHtml(rawText)}</code></p> 228 295 </div> 229 296 `; 230 297 statsDiv.textContent = ''; 231 298 return; 232 299 } 233 300 234 - let html = `<div style="font-size:0.7rem;color:#404040;margin-bottom:1rem;padding:0.5rem;background:#111;border-radius:4px"> 235 - query: <code>${escapeHtml(query)}</code> | ${results.length} results 236 - </div>`; 301 + let html = ''; 237 302 238 303 for (const doc of results) { 239 304 const entityType = doc.type || 'article'; ··· 286 351 })[c]); 287 352 } 288 353 289 - searchBtn.addEventListener('click', () => { 290 - const q = queryInput.value; 291 - if (q.trim()) history.pushState(null, '', `?q=${encodeURIComponent(q)}`); 292 - search(q); 293 - }); 294 - queryInput.addEventListener('keydown', e => { 295 - if (e.key === 'Enter') { 296 - const q = queryInput.value; 297 - if (q.trim()) history.pushState(null, '', `?q=${encodeURIComponent(q)}`); 298 - search(q); 354 + function updateUrl() { 355 + const params = new URLSearchParams(); 356 + const q = queryInput.value.trim(); 357 + if (q) params.set('q', q); 358 + if (currentTag) params.set('tag', currentTag); 359 + const url = params.toString() ? `?${params}` : '/'; 360 + history.pushState(null, '', url); 361 + } 362 + 363 + function doSearch() { 364 + updateUrl(); 365 + search(queryInput.value, currentTag); 366 + } 367 + 368 + function setTag(tag) { 369 + currentTag = tag; 370 + renderActiveFilter(); 371 + renderTags(); 372 + doSearch(); 373 + } 374 + 375 + function clearTag() { 376 + currentTag = null; 377 + renderActiveFilter(); 378 + renderTags(); 379 + updateUrl(); 380 + if (!queryInput.value.trim()) { 381 + resultsDiv.innerHTML = ` 382 + <div class="empty-state"> 383 + <p>full-text search for leaflet documents</p> 384 + <p>searches titles and content</p> 385 + </div> 386 + `; 387 + } 388 + } 389 + 390 + function renderActiveFilter() { 391 + if (!currentTag) { 392 + activeFilterDiv.innerHTML = ''; 393 + return; 394 + } 395 + activeFilterDiv.innerHTML = ` 396 + <div class="active-filter"> 397 + <span>filtering by tag: <strong>#${escapeHtml(currentTag)}</strong></span> 398 + <span class="clear" onclick="clearTag()">× clear</span> 399 + </div> 400 + `; 401 + } 402 + 403 + function renderTags() { 404 + if (allTags.length === 0) { 405 + tagsDiv.innerHTML = ''; 406 + return; 407 + } 408 + const html = allTags.slice(0, 15).map(t => ` 409 + <span class="tag${currentTag === t.tag ? ' active' : ''}" onclick="setTag('${escapeHtml(t.tag)}')">${escapeHtml(t.tag)}<span class="count">${t.count}</span></span> 410 + `).join(''); 411 + tagsDiv.innerHTML = `<div class="tags-list">${html}</div>`; 412 + } 413 + 414 + async function loadTags() { 415 + try { 416 + const res = await fetch(`${API_URL}/tags`); 417 + allTags = await res.json(); 418 + renderTags(); 419 + } catch (e) { 420 + console.error('failed to load tags', e); 299 421 } 422 + } 423 + 424 + searchBtn.addEventListener('click', doSearch); 425 + queryInput.addEventListener('keydown', e => { 426 + if (e.key === 'Enter') doSearch(); 300 427 }); 301 428 302 - // handle back/forward navigation 303 429 window.addEventListener('popstate', () => { 304 430 const params = new URLSearchParams(location.search); 305 - const q = params.get('q') || ''; 306 - queryInput.value = q; 307 - if (q) search(q); 431 + queryInput.value = params.get('q') || ''; 432 + currentTag = params.get('tag') || null; 433 + renderActiveFilter(); 434 + renderTags(); 435 + if (queryInput.value || currentTag) search(queryInput.value, currentTag); 308 436 }); 309 437 310 - // check for ?q= on page load 438 + // init 311 439 const initialParams = new URLSearchParams(location.search); 312 440 const initialQuery = initialParams.get('q'); 313 - if (initialQuery) { 314 - queryInput.value = initialQuery; 315 - search(initialQuery); 441 + const initialTag = initialParams.get('tag'); 442 + if (initialQuery) queryInput.value = initialQuery; 443 + if (initialTag) currentTag = initialTag; 444 + renderActiveFilter(); 445 + 446 + if (initialQuery || initialTag) { 447 + search(initialQuery || '', initialTag); 316 448 } 317 449 318 - // check backend health on load 450 + loadTags(); 451 + 319 452 fetch(`${API_URL}/stats`) 320 453 .then(r => r.json()) 321 454 .then(data => {
+1
tap/fly.toml
··· 12 12 TAP_DISABLE_ACKS = 'true' 13 13 TAP_LOG_LEVEL = 'info' 14 14 TAP_CURSOR_SAVE_INTERVAL = '5s' 15 + TAP_REPO_FETCH_TIMEOUT = '600s' 15 16 16 17 [http_service] 17 18 internal_port = 2480