The Appview for the kipclip.com atproto bookmarking service

feat: unified search and tag filtering with tag: syntax

Sidebar tag clicks now populate the search bar with tag:tagname tokens,
and users can type tag:xxx directly. Active tags show as removable pills
above results, with matching tag suggestions when free text matches.

+267 -22
+92 -4
frontend/components/BookmarkList.tsx
··· 1 - import { useCallback, useEffect, useRef, useState } from "react"; 1 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 2 import { AddBookmark } from "./AddBookmark.tsx"; 3 3 import { 4 4 BookmarkCard, ··· 17 17 import { useApp } from "../context/AppContext.tsx"; 18 18 import type { DateFormatOption } from "../../shared/date-format.ts"; 19 19 import type { EnrichedBookmark, EnrichedTag } from "../../shared/types.ts"; 20 + import { parseSearchQuery } from "../../shared/search-query.ts"; 20 21 21 22 function useIncrementalRender(total: number, pageSize = 100) { 22 23 const [visible, setVisible] = useState(pageSize); ··· 108 109 ); 109 110 }, [setBookmarkSearchQuery]); 110 111 useEffect(() => () => clearTimeout(searchTimerRef.current), []); 112 + 113 + // Sync external query changes (e.g. sidebar tag clicks) to local input, 114 + // and cancel any pending debounce to prevent stale timer overwrites 115 + useEffect(() => { 116 + clearTimeout(searchTimerRef.current); 117 + setLocalSearchQuery(bookmarkSearchQuery); 118 + }, [bookmarkSearchQuery]); 119 + 120 + // Auto-expand mobile search when query becomes non-empty (e.g. sidebar tag click) 121 + useEffect(() => { 122 + if (bookmarkSearchQuery.trim()) { 123 + setMobileSearchOpen(true); 124 + } 125 + }, [bookmarkSearchQuery]); 126 + 127 + // Parse query for matching tag suggestions 128 + const parsedQuery = useMemo( 129 + () => parseSearchQuery(bookmarkSearchQuery), 130 + [bookmarkSearchQuery], 131 + ); 132 + 133 + // Matching tags: suggest tags that match the free text portion of the query 134 + const matchingTags = useMemo(() => { 135 + const text = parsedQuery.text.toLowerCase(); 136 + if (!text) return []; 137 + return availableTags 138 + .filter((t) => 139 + t.value.toLowerCase().includes(text) && 140 + !selectedTags.has(t.value.toLowerCase()) 141 + ) 142 + .slice(0, 8); 143 + }, [parsedQuery.text, availableTags, selectedTags]); 111 144 112 145 // Orphaned tags prompt 113 146 const [orphanedTags, setOrphanedTags] = useState<EnrichedTag[]>([]); ··· 459 492 type="button" 460 493 onClick={() => { 461 494 setMobileSearchOpen(false); 462 - setLocalSearchQuery(""); 463 - setBookmarkSearchQuery(""); 464 495 }} 465 496 className="p-2 text-gray-500 hover:text-gray-700" 466 497 > ··· 577 608 </div> 578 609 </div> 579 610 611 + {/* Active tag filters and matching tag suggestions */} 612 + {(selectedTags.size > 0 || matchingTags.length > 0) && ( 613 + <div className="mb-4 flex flex-wrap gap-2 items-center"> 614 + {selectedTags.size > 0 && ( 615 + <> 616 + {availableTags 617 + .filter((t) => selectedTags.has(t.value.toLowerCase())) 618 + .map((tag) => ( 619 + <button 620 + key={tag.uri} 621 + type="button" 622 + onClick={() => toggleTag(tag.value)} 623 + className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition flex items-center gap-1.5" 624 + > 625 + {tag.value} 626 + <svg 627 + className="w-3.5 h-3.5" 628 + fill="none" 629 + stroke="currentColor" 630 + viewBox="0 0 24 24" 631 + > 632 + <path 633 + strokeLinecap="round" 634 + strokeLinejoin="round" 635 + strokeWidth={2} 636 + d="M6 18L18 6M6 6l12 12" 637 + /> 638 + </svg> 639 + </button> 640 + ))} 641 + </> 642 + )} 643 + {matchingTags.length > 0 && ( 644 + <> 645 + {selectedTags.size > 0 && ( 646 + <span className="text-gray-300 mx-1">|</span> 647 + )} 648 + {matchingTags.map((tag) => ( 649 + <button 650 + key={tag.uri} 651 + type="button" 652 + onClick={() => toggleTag(tag.value)} 653 + className="px-3 py-1.5 text-sm rounded-lg text-gray-700 hover:bg-gray-100 transition" 654 + style={{ 655 + border: "1px solid #e5e7eb", 656 + }} 657 + > 658 + {tag.value} 659 + </button> 660 + ))} 661 + </> 662 + )} 663 + </div> 664 + )} 665 + 580 666 {bookmarks.length === 0 581 667 ? ( 582 668 <div className="text-center py-20"> ··· 699 785 for (const uri of deletedTagUris) { 700 786 const tag = orphanedTags.find((t) => t.uri === uri); 701 787 deleteTag(uri); 702 - if (tag && selectedTags.has(tag.value)) toggleTag(tag.value); 788 + if (tag && selectedTags.has(tag.value.toLowerCase())) { 789 + toggleTag(tag.value); 790 + } 703 791 } 704 792 setOrphanedTags([]); 705 793 }}
+7 -4
frontend/components/TagSidebar.tsx
··· 57 57 if (!session || selectedTags.size === 0) return; 58 58 59 59 try { 60 - // Generate share URL 61 - const encodedTags = encodeTagsForUrl([...selectedTags]); 60 + // Look up original-case tag values for the share URL 61 + const originalCaseTags = tags 62 + .filter((t) => selectedTags.has(t.value.toLowerCase())) 63 + .map((t) => t.value); 64 + const encodedTags = encodeTagsForUrl(originalCaseTags); 62 65 const shareUrl = 63 66 `${globalThis.location.origin}/share/${session.did}/${encodedTags}`; 64 67 ··· 67 70 await navigator.share({ 68 71 title: "My Kipclip Bookmarks Collection", 69 72 text: `Check out my bookmarks collection tagged with: ${ 70 - [...selectedTags].join(", ") 73 + originalCaseTags.join(", ") 71 74 }`, 72 75 url: shareUrl, 73 76 }); ··· 87 90 88 91 // Shared tag item renderer 89 92 function renderTag(tag: any) { 90 - const isSelected = selectedTags.has(tag.value); 93 + const isSelected = selectedTags.has(tag.value.toLowerCase()); 91 94 return ( 92 95 <li 93 96 key={tag.uri}
+19 -14
frontend/context/AppContext.tsx
··· 38 38 filterByTags, 39 39 matchesSearch, 40 40 } from "../../shared/bookmark-filters.ts"; 41 + import { 42 + parseSearchQuery, 43 + toggleTagInQuery, 44 + } from "../../shared/search-query.ts"; 41 45 42 46 const DEFAULT_SETTINGS: UserSettings = { 43 47 instapaperEnabled: false, ··· 125 129 setTagsRaw(sortTags(update)); 126 130 } 127 131 }; 128 - const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set()); 129 132 const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS); 130 133 const [preferences, setPreferences] = useState<UserPreferences>( 131 134 DEFAULT_PREFERENCES, ··· 359 362 } 360 363 } 361 364 365 + // Derive selectedTags from search query 366 + const parsedQuery = useMemo( 367 + () => parseSearchQuery(bookmarkSearchQuery), 368 + [bookmarkSearchQuery], 369 + ); 370 + const selectedTags = useMemo( 371 + () => new Set(parsedQuery.tags), 372 + [parsedQuery.tags], 373 + ); 374 + 362 375 // Filter actions 363 376 function toggleTag(tagValue: string) { 364 - setSelectedTags((prev) => { 365 - const newSet = new Set(prev); 366 - if (newSet.has(tagValue)) { 367 - newSet.delete(tagValue); 368 - } else { 369 - newSet.add(tagValue); 370 - } 371 - return newSet; 372 - }); 377 + setBookmarkSearchQuery(toggleTagInQuery(bookmarkSearchQuery, tagValue)); 373 378 } 374 379 375 380 function clearFilters() { 376 - setSelectedTags(new Set()); 381 + setBookmarkSearchQuery(parsedQuery.text); 377 382 } 378 383 379 384 // Reading list filter actions ··· 401 406 perf.start("tagFilter"); 402 407 let result = filterByTags(bookmarks, selectedTags, tagIndex); 403 408 404 - if (bookmarkSearchQuery.trim()) { 405 - result = result.filter((b) => matchesSearch(b, bookmarkSearchQuery)); 409 + if (parsedQuery.text.trim()) { 410 + result = result.filter((b) => matchesSearch(b, parsedQuery.text)); 406 411 } 407 412 408 413 perf.end("tagFilter"); 409 414 return result; 410 - }, [bookmarks, tagIndex, selectedTags, bookmarkSearchQuery]); 415 + }, [bookmarks, tagIndex, selectedTags, parsedQuery.text]); 411 416 412 417 const readingListBookmarks = useMemo( 413 418 () => {
+67
shared/search-query.ts
··· 1 + /** 2 + * Pure functions for parsing and manipulating search queries with tag: syntax. 3 + * No React dependencies — used by both AppContext and tests. 4 + */ 5 + 6 + export interface ParsedQuery { 7 + tags: string[]; 8 + text: string; 9 + } 10 + 11 + /** 12 + * Parse a search query string, extracting `tag:xxx` tokens. 13 + * - Case-insensitive prefix (`TAG:`, `Tag:`, `tag:` all work) 14 + * - Tag values are lowercased and deduplicated 15 + * - `tag:` with no value is treated as literal text 16 + * - Remaining non-tag tokens are joined as free text 17 + */ 18 + export function parseSearchQuery(raw: string): ParsedQuery { 19 + const tokens = raw.split(/\s+/).filter((t) => t.length > 0); 20 + const tags: string[] = []; 21 + const textParts: string[] = []; 22 + const seen = new Set<string>(); 23 + 24 + for (const token of tokens) { 25 + if (token.toLowerCase().startsWith("tag:") && token.length > 4) { 26 + const value = token.slice(4).toLowerCase(); 27 + if (!seen.has(value)) { 28 + seen.add(value); 29 + tags.push(value); 30 + } 31 + } else { 32 + textParts.push(token); 33 + } 34 + } 35 + 36 + return { tags, text: textParts.join(" ") }; 37 + } 38 + 39 + /** 40 + * Toggle a tag in a query string. 41 + * - If the tag is present, removes it 42 + * - If absent, prepends `tag:tagname` at the start 43 + * - Case-insensitive matching for removal 44 + */ 45 + export function toggleTagInQuery(query: string, tag: string): string { 46 + const tagLower = tag.toLowerCase(); 47 + const tokens = query.split(/\s+/).filter((t) => t.length > 0); 48 + 49 + // Check if tag already exists 50 + const exists = tokens.some((t) => 51 + t.toLowerCase().startsWith("tag:") && t.length > 4 && 52 + t.slice(4).toLowerCase() === tagLower 53 + ); 54 + 55 + if (exists) { 56 + // Remove it 57 + const remaining = tokens.filter((t) => 58 + !(t.toLowerCase().startsWith("tag:") && t.length > 4 && 59 + t.slice(4).toLowerCase() === tagLower) 60 + ); 61 + return remaining.join(" "); 62 + } 63 + 64 + // Prepend 65 + const prefix = `tag:${tagLower}`; 66 + return query.trim().length > 0 ? `${prefix} ${query.trim()}` : prefix; 67 + }
+82
tests/search-query.test.ts
··· 1 + /** 2 + * Tests for search query parsing with tag: syntax. 3 + */ 4 + 5 + import { assertEquals } from "@std/assert"; 6 + import { parseSearchQuery, toggleTagInQuery } from "../shared/search-query.ts"; 7 + 8 + // ============================================================================ 9 + // parseSearchQuery 10 + // ============================================================================ 11 + 12 + Deno.test("parseSearchQuery - empty string", () => { 13 + const result = parseSearchQuery(""); 14 + assertEquals(result, { tags: [], text: "" }); 15 + }); 16 + 17 + Deno.test("parseSearchQuery - plain text only", () => { 18 + const result = parseSearchQuery("hello world"); 19 + assertEquals(result, { tags: [], text: "hello world" }); 20 + }); 21 + 22 + Deno.test("parseSearchQuery - single tag", () => { 23 + const result = parseSearchQuery("tag:swift"); 24 + assertEquals(result, { tags: ["swift"], text: "" }); 25 + }); 26 + 27 + Deno.test("parseSearchQuery - multiple tags", () => { 28 + const result = parseSearchQuery("tag:swift tag:ios"); 29 + assertEquals(result, { tags: ["swift", "ios"], text: "" }); 30 + }); 31 + 32 + Deno.test("parseSearchQuery - mixed tags and text", () => { 33 + const result = parseSearchQuery("tag:swift tutorials"); 34 + assertEquals(result, { tags: ["swift"], text: "tutorials" }); 35 + }); 36 + 37 + Deno.test("parseSearchQuery - case-insensitive prefix", () => { 38 + const result = parseSearchQuery("TAG:Swift"); 39 + assertEquals(result, { tags: ["swift"], text: "" }); 40 + }); 41 + 42 + Deno.test("parseSearchQuery - deduplicates tags", () => { 43 + const result = parseSearchQuery("tag:swift tag:SWIFT"); 44 + assertEquals(result, { tags: ["swift"], text: "" }); 45 + }); 46 + 47 + Deno.test("parseSearchQuery - tag: with no value is literal text", () => { 48 + const result = parseSearchQuery("tag:"); 49 + assertEquals(result, { tags: [], text: "tag:" }); 50 + }); 51 + 52 + Deno.test("parseSearchQuery - tags interspersed with text", () => { 53 + const result = parseSearchQuery("how to tag:swift learn tag:ios today"); 54 + assertEquals(result, { tags: ["swift", "ios"], text: "how to learn today" }); 55 + }); 56 + 57 + // ============================================================================ 58 + // toggleTagInQuery 59 + // ============================================================================ 60 + 61 + Deno.test("toggleTagInQuery - add to empty query", () => { 62 + assertEquals(toggleTagInQuery("", "swift"), "tag:swift"); 63 + }); 64 + 65 + Deno.test("toggleTagInQuery - add to existing text (prepend)", () => { 66 + assertEquals(toggleTagInQuery("tutorials", "swift"), "tag:swift tutorials"); 67 + }); 68 + 69 + Deno.test("toggleTagInQuery - remove existing tag", () => { 70 + assertEquals(toggleTagInQuery("tag:swift tutorials", "swift"), "tutorials"); 71 + }); 72 + 73 + Deno.test("toggleTagInQuery - case-insensitive removal", () => { 74 + assertEquals(toggleTagInQuery("tag:swift tutorials", "Swift"), "tutorials"); 75 + }); 76 + 77 + Deno.test("toggleTagInQuery - remove only matching tag, preserve others", () => { 78 + assertEquals( 79 + toggleTagInQuery("tag:swift tag:ios tutorials", "swift"), 80 + "tag:ios tutorials", 81 + ); 82 + });