experiments in a post-browser web
10
fork

Configure Feed

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

feat(tags): add content search with tag combo filtering, pin selected tags in sidebar

+114 -69
+30 -30
extensions/tags/home.css
··· 57 57 flex-wrap: wrap; 58 58 } 59 59 60 - .active-tag-chip { 61 - display: flex; 62 - align-items: center; 63 - gap: 4px; 64 - padding: 4px 10px; 65 - background: var(--base0D); 66 - color: var(--base00); 67 - border-radius: 12px; 68 - } 69 - 70 - .active-tag-chip .clear-tag { 71 - background: none; 72 - border: none; 73 - color: var(--base00); 74 - cursor: pointer; 75 - font-size: 14px; 76 - line-height: 1; 77 - padding: 0; 78 - margin-left: 2px; 79 - opacity: 0.8; 80 - } 81 - 82 - .active-tag-chip .clear-tag:hover { 83 - opacity: 1; 60 + .active-tag-badge { 61 + font-size: 12px; 62 + font-weight: 500; 63 + color: var(--base0D); 84 64 } 85 65 86 66 .clear-all-tags { ··· 216 196 .tag-chip { 217 197 display: flex; 218 198 align-items: center; 219 - justify-content: space-between; 199 + gap: 6px; 220 200 padding: 8px 10px; 221 201 background: var(--base01); 222 202 border: 1px solid var(--base02); ··· 225 205 color: var(--base05); 226 206 cursor: pointer; 227 207 transition: all 0.15s; 208 + } 209 + 210 + .tag-chip .tag-name { 211 + flex: 1; 212 + min-width: 0; 213 + overflow: hidden; 214 + text-overflow: ellipsis; 215 + white-space: nowrap; 216 + } 217 + 218 + .tag-chip .tag-count { 219 + font-size: 11px; 220 + opacity: 0.7; 221 + flex-shrink: 0; 228 222 } 229 223 230 224 .tag-chip:hover { ··· 238 232 color: var(--base00); 239 233 } 240 234 241 - .tag-chip .tag-count { 242 - font-size: 11px; 243 - opacity: 0.7; 244 - } 245 - 246 235 .tag-chip.selected .tag-count { 247 236 opacity: 0.9; 237 + } 238 + 239 + .tag-chip .tag-check { 240 + flex-shrink: 0; 241 + } 242 + 243 + /* Separator between selected and unselected tags */ 244 + .tag-separator { 245 + height: 1px; 246 + background: var(--base02); 247 + margin: 6px 0; 248 248 } 249 249 250 250 /* Items container */
+1 -1
extensions/tags/home.html
··· 84 84 <div class="search-container"> 85 85 <peek-input 86 86 class="search-input" 87 - placeholder="Search items and tags..." 87 + placeholder="Search content, titles, and tags..." 88 88 type="search" 89 89 ></peek-input> 90 90 </div>
+83 -38
extensions/tags/home.js
··· 5 5 * - View all saved items (addresses) filtered by type 6 6 * - Tag-based filtering via clickable tag buttons 7 7 * - Tag editing on items (add/remove tags) 8 - * - Search across items and tags 8 + * - Content search across items (URLs, titles, text) with tag combo filtering 9 + * - Selected tags pinned to top of sidebar with visual distinction 9 10 */ 10 11 11 12 const api = window.app; ··· 436 437 }; 437 438 438 439 /** 439 - * Filter items based on current state 440 + * Filter items based on current state. 441 + * 442 + * Search behaviour: 443 + * - Search query filters items by content, title, URL, domain, AND tag names. 444 + * - When tags are selected, items must have ALL selected tags (AND logic) 445 + * AND match the search query against content/title/URL/tags. 440 446 */ 441 447 const getFilteredItems = () => { 442 448 let items = [...state.items]; ··· 461 467 }); 462 468 } 463 469 464 - // Filter by search query - match tag names (supports multiple tags separated by comma or space) 470 + // Filter by search query - searches content, title, URL, domain, and tag names 465 471 if (state.searchQuery) { 466 - // Split by comma or space, filter empty strings 467 - const searchTerms = state.searchQuery.toLowerCase() 468 - .split(/[,\s]+/) 469 - .map(t => t.trim()) 470 - .filter(t => t.length > 0); 472 + const q = state.searchQuery.toLowerCase().trim(); 473 + if (q.length > 0) { 474 + items = items.filter(item => { 475 + // Search item content fields 476 + const content = (item.content || '').toLowerCase(); 477 + const title = (item.title || '').toLowerCase(); 478 + const uri = (item.uri || '').toLowerCase(); 479 + const domain = (item.domain || '').toLowerCase(); 480 + 481 + if (content.includes(q) || title.includes(q) || uri.includes(q) || domain.includes(q)) { 482 + return true; 483 + } 471 484 472 - if (searchTerms.length > 0) { 473 - items = items.filter(item => { 485 + // Also search tag names on the item 474 486 const tags = state.itemTags.get(item.id) || []; 475 - const tagNames = tags.map(t => t.name.toLowerCase()); 476 - // Item must have tags matching ALL search terms (AND logic) 477 - return searchTerms.every(term => 478 - tagNames.some(tagName => tagName.includes(term)) 479 - ); 487 + return tags.some(t => t.name.toLowerCase().includes(q)); 480 488 }); 481 489 } 482 490 } ··· 485 493 }; 486 494 487 495 /** 488 - * Filter tags based on search query 496 + * Filter tags based on search query. 497 + * When tags are selected, don't filter the tag list by search (search applies to items instead). 498 + * Selected tags are always included regardless of search filter. 489 499 */ 490 500 const getFilteredTags = () => { 491 - if (!state.searchQuery) return state.tags; 501 + if (!state.searchQuery || state.activeTags.length > 0) return state.tags; 492 502 const q = state.searchQuery.toLowerCase(); 493 - return state.tags.filter(tag => tag.name.toLowerCase().includes(q)); 503 + const selectedIds = new Set(state.activeTags.map(t => t.id)); 504 + return state.tags.filter(tag => 505 + selectedIds.has(tag.id) || tag.name.toLowerCase().includes(q) 506 + ); 494 507 }; 495 508 496 509 /** ··· 501 514 renderTagSidebar(); 502 515 renderCards(); 503 516 renderActiveTagIndicator(); 517 + updateSearchPlaceholder(); 518 + }; 519 + 520 + /** 521 + * Update search placeholder based on whether tags are selected 522 + */ 523 + const updateSearchPlaceholder = () => { 524 + if (state.activeTags.length > 0) { 525 + searchInput.placeholder = 'Search within tagged items...'; 526 + } else { 527 + searchInput.placeholder = 'Search content, titles, and tags...'; 528 + } 504 529 }; 505 530 506 531 /** ··· 514 539 }; 515 540 516 541 /** 517 - * Render active tag indicator in header 542 + * Render active tag indicator in header. 543 + * Shows a compact filter count + clear button. The sidebar is the primary 544 + * location for selected tags (pinned to top with visual distinction). 518 545 */ 519 546 const renderActiveTagIndicator = () => { 520 547 const indicator = document.querySelector('.active-tag-indicator'); ··· 522 549 if (state.activeTags.length > 0) { 523 550 indicator.innerHTML = ''; 524 551 525 - state.activeTags.forEach(tag => { 526 - const chip = document.createElement('peek-button'); 527 - chip.variant = 'primary'; 528 - chip.size = 'sm'; 529 - chip.className = 'active-tag-chip'; 530 - chip.dataset.tagId = tag.id; 531 - chip.innerHTML = ` 532 - ${escapeHtml(tag.name)} 533 - <span slot="suffix" class="clear-tag" title="Remove tag">&times;</span> 534 - `; 535 - chip.addEventListener('click', () => removeActiveTag(tag.id)); 536 - indicator.appendChild(chip); 537 - }); 552 + const badge = document.createElement('span'); 553 + badge.className = 'active-tag-badge'; 554 + badge.textContent = `${state.activeTags.length} tag${state.activeTags.length > 1 ? 's' : ''} selected`; 555 + indicator.appendChild(badge); 538 556 539 557 const clearAll = document.createElement('peek-button'); 540 558 clearAll.variant = 'ghost'; 541 559 clearAll.size = 'sm'; 542 560 clearAll.className = 'clear-all-tags'; 543 - clearAll.textContent = '× All'; 544 - clearAll.title = 'Clear all filters'; 561 + clearAll.textContent = 'Clear'; 562 + clearAll.title = 'Clear all tag filters'; 545 563 clearAll.addEventListener('click', clearTagFilter); 546 564 indicator.appendChild(clearAll); 547 565 ··· 571 589 }; 572 590 573 591 /** 574 - * Render the tag sidebar 592 + * Render the tag sidebar. 593 + * Selected tags are pinned to the top of the list with a visual separator. 575 594 */ 576 595 const renderTagSidebar = () => { 577 596 const tags = getFilteredTags(); ··· 583 602 584 603 tagList.innerHTML = ''; 585 604 586 - tags.forEach(tag => { 605 + const selectedIds = new Set(state.activeTags.map(t => t.id)); 606 + const selectedTags = tags.filter(t => selectedIds.has(t.id)); 607 + const unselectedTags = tags.filter(t => !selectedIds.has(t.id)); 608 + 609 + // Helper to build a tag chip 610 + const buildTagChip = (tag, isSelected) => { 587 611 // Count items with this tag 588 612 let count = 0; 589 613 state.itemTags.forEach(itemTags => { ··· 592 616 593 617 const chip = document.createElement('div'); 594 618 chip.className = 'tag-chip'; 595 - const isSelected = state.activeTags.some(t => t.id === tag.id); 596 619 if (isSelected) { 597 620 chip.classList.add('selected'); 598 621 } 599 622 623 + const checkIcon = isSelected 624 + ? '<svg class="tag-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>' 625 + : ''; 626 + 600 627 chip.innerHTML = ` 628 + ${checkIcon} 601 629 <span class="tag-name">${escapeHtml(tag.name)}</span> 602 630 <span class="tag-count">${count}</span> 603 631 `; ··· 613 641 render(); 614 642 }); 615 643 616 - tagList.appendChild(chip); 644 + return chip; 645 + }; 646 + 647 + // Render selected tags pinned at top 648 + selectedTags.forEach(tag => { 649 + tagList.appendChild(buildTagChip(tag, true)); 650 + }); 651 + 652 + // Add separator between selected and unselected 653 + if (selectedTags.length > 0 && unselectedTags.length > 0) { 654 + const separator = document.createElement('div'); 655 + separator.className = 'tag-separator'; 656 + tagList.appendChild(separator); 657 + } 658 + 659 + // Render unselected tags 660 + unselectedTags.forEach(tag => { 661 + tagList.appendChild(buildTagChip(tag, false)); 617 662 }); 618 663 }; 619 664