WIP PWA for Grain

docs: add explore/search feature implementation plan

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

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

+616
+616
docs/plans/2025-12-26-explore-search.md
··· 1 + # Explore/Search Feature Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a search/explore page accessible from the bottom nav where users can search galleries and people with instant results. 6 + 7 + **Architecture:** New `/explore` route with tabbed search interface. Galleries tab (default) shows full gallery cards, People tab shows profile cards. Debounced instant search queries the GraphQL API with `contains` filter. 8 + 9 + **Tech Stack:** Lit components, GraphQL via grain-api.js, existing component library. 10 + 11 + --- 12 + 13 + ### Task 1: Add search icon to grain-icon.js 14 + 15 + **Files:** 16 + - Modify: `src/components/atoms/grain-icon.js:4-20` 17 + 18 + **Step 1: Add search icons to ICONS object** 19 + 20 + Add after line 15 (`plus`): 21 + 22 + ```javascript 23 + search: 'fa-solid fa-magnifying-glass', 24 + searchLine: 'fa-regular fa-magnifying-glass', 25 + ``` 26 + 27 + **Step 2: Verify icon renders** 28 + 29 + Open browser devtools console and run: 30 + ```javascript 31 + document.createElement('grain-icon').setAttribute('name', 'search'); 32 + ``` 33 + 34 + **Step 3: Commit** 35 + 36 + ```bash 37 + git add src/components/atoms/grain-icon.js 38 + git commit -m "feat: add search icons to grain-icon" 39 + ``` 40 + 41 + --- 42 + 43 + ### Task 2: Add search API methods to grain-api.js 44 + 45 + **Files:** 46 + - Modify: `src/services/grain-api.js` 47 + 48 + **Step 1: Add searchGalleries method** 49 + 50 + Add after the `getTimeline` method (after line 100): 51 + 52 + ```javascript 53 + async searchGalleries(query, { first = 10, after = null } = {}) { 54 + const gqlQuery = ` 55 + query SearchGalleries($query: String!, $first: Int, $after: String) { 56 + socialGrainGallery( 57 + first: $first 58 + after: $after 59 + where: { title: { contains: $query } } 60 + sortBy: [{ field: createdAt, direction: DESC }] 61 + ) { 62 + edges { 63 + node { 64 + uri 65 + title 66 + description 67 + createdAt 68 + actorHandle 69 + socialGrainActorProfileByDid { 70 + avatar { url } 71 + displayName 72 + } 73 + socialGrainGalleryItemViaGallery(first: 10, sortBy: [{ field: position, direction: ASC }]) { 74 + edges { 75 + node { 76 + itemResolved { 77 + ... on SocialGrainPhoto { 78 + uri 79 + alt 80 + aspectRatio { width height } 81 + photo { url } 82 + } 83 + } 84 + } 85 + } 86 + } 87 + socialGrainFavoriteViaSubject { 88 + totalCount 89 + } 90 + socialGrainCommentViaSubject { 91 + totalCount 92 + } 93 + } 94 + } 95 + pageInfo { 96 + hasNextPage 97 + endCursor 98 + } 99 + } 100 + } 101 + `; 102 + 103 + const response = await this.#execute(gqlQuery, { query, first, after }); 104 + return this.#transformTimelineResponse(response); 105 + } 106 + ``` 107 + 108 + **Step 2: Add searchProfiles method** 109 + 110 + Add after `searchGalleries`: 111 + 112 + ```javascript 113 + async searchProfiles(query, { first = 20, after = null } = {}) { 114 + const gqlQuery = ` 115 + query SearchProfiles($query: String!, $first: Int, $after: String) { 116 + socialGrainActorProfile( 117 + first: $first 118 + after: $after 119 + where: { actorHandle: { contains: $query } } 120 + ) { 121 + edges { 122 + node { 123 + actorHandle 124 + displayName 125 + description 126 + avatar { url } 127 + } 128 + } 129 + pageInfo { 130 + hasNextPage 131 + endCursor 132 + } 133 + } 134 + } 135 + `; 136 + 137 + const response = await this.#execute(gqlQuery, { query, first, after }); 138 + const connection = response.data?.socialGrainActorProfile; 139 + 140 + if (!connection) return { profiles: [], pageInfo: { hasNextPage: false } }; 141 + 142 + const profiles = connection.edges.map(edge => ({ 143 + handle: edge.node.actorHandle, 144 + displayName: edge.node.displayName || '', 145 + description: edge.node.description || '', 146 + avatarUrl: edge.node.avatar?.url || '' 147 + })); 148 + 149 + return { 150 + profiles, 151 + pageInfo: connection.pageInfo 152 + }; 153 + } 154 + ``` 155 + 156 + **Step 3: Commit** 157 + 158 + ```bash 159 + git add src/services/grain-api.js 160 + git commit -m "feat: add searchGalleries and searchProfiles API methods" 161 + ``` 162 + 163 + --- 164 + 165 + ### Task 3: Create grain-explore page component 166 + 167 + **Files:** 168 + - Create: `src/components/pages/grain-explore.js` 169 + 170 + **Step 1: Create the explore page** 171 + 172 + ```javascript 173 + import { LitElement, html, css } from 'lit'; 174 + import { grainApi } from '../../services/grain-api.js'; 175 + import '../templates/grain-feed-layout.js'; 176 + import '../organisms/grain-gallery-card.js'; 177 + import '../molecules/grain-profile-card.js'; 178 + import '../atoms/grain-spinner.js'; 179 + 180 + export class GrainExplore extends LitElement { 181 + static properties = { 182 + _query: { state: true }, 183 + _activeTab: { state: true }, 184 + _galleries: { state: true }, 185 + _profiles: { state: true }, 186 + _loading: { state: true }, 187 + _hasMore: { state: true }, 188 + _cursor: { state: true } 189 + }; 190 + 191 + static styles = css` 192 + :host { 193 + display: block; 194 + } 195 + .search-container { 196 + position: sticky; 197 + top: 48px; 198 + background: var(--color-bg-primary); 199 + padding: var(--space-sm); 200 + z-index: 10; 201 + border-bottom: 1px solid var(--color-border); 202 + } 203 + .search-input-wrapper { 204 + position: relative; 205 + max-width: var(--feed-max-width); 206 + margin: 0 auto; 207 + } 208 + input { 209 + width: 100%; 210 + padding: var(--space-sm) var(--space-md); 211 + padding-right: 40px; 212 + border: 1px solid var(--color-border); 213 + border-radius: 20px; 214 + background: var(--color-bg-secondary); 215 + color: var(--color-text-primary); 216 + font-size: var(--font-size-sm); 217 + box-sizing: border-box; 218 + } 219 + input::placeholder { 220 + color: var(--color-text-secondary); 221 + } 222 + input:focus { 223 + outline: none; 224 + border-color: var(--color-text-secondary); 225 + } 226 + .clear-btn { 227 + position: absolute; 228 + right: 12px; 229 + top: 50%; 230 + transform: translateY(-50%); 231 + background: none; 232 + border: none; 233 + color: var(--color-text-secondary); 234 + cursor: pointer; 235 + padding: 4px; 236 + font-size: 16px; 237 + } 238 + .tabs { 239 + display: flex; 240 + max-width: var(--feed-max-width); 241 + margin: 0 auto; 242 + border-bottom: 1px solid var(--color-border); 243 + } 244 + .tab { 245 + flex: 1; 246 + padding: var(--space-sm) var(--space-md); 247 + background: none; 248 + border: none; 249 + color: var(--color-text-secondary); 250 + font-size: var(--font-size-sm); 251 + cursor: pointer; 252 + border-bottom: 2px solid transparent; 253 + } 254 + .tab.active { 255 + color: var(--color-text-primary); 256 + font-weight: var(--font-weight-semibold); 257 + border-bottom-color: var(--color-text-primary); 258 + } 259 + .empty { 260 + padding: var(--space-xl); 261 + text-align: center; 262 + color: var(--color-text-secondary); 263 + } 264 + .profiles-list { 265 + max-width: var(--feed-max-width); 266 + margin: 0 auto; 267 + padding: var(--space-sm); 268 + } 269 + #sentinel { 270 + height: 1px; 271 + } 272 + `; 273 + 274 + #debounceTimer = null; 275 + #observer = null; 276 + 277 + constructor() { 278 + super(); 279 + this._query = ''; 280 + this._activeTab = 'galleries'; 281 + this._galleries = []; 282 + this._profiles = []; 283 + this._loading = false; 284 + this._hasMore = false; 285 + this._cursor = null; 286 + } 287 + 288 + disconnectedCallback() { 289 + super.disconnectedCallback(); 290 + this.#observer?.disconnect(); 291 + if (this.#debounceTimer) clearTimeout(this.#debounceTimer); 292 + } 293 + 294 + firstUpdated() { 295 + this.#setupInfiniteScroll(); 296 + } 297 + 298 + #handleInput(e) { 299 + const value = e.target.value; 300 + this._query = value; 301 + 302 + if (this.#debounceTimer) clearTimeout(this.#debounceTimer); 303 + 304 + if (value.length < 2) { 305 + this._galleries = []; 306 + this._profiles = []; 307 + this._hasMore = false; 308 + return; 309 + } 310 + 311 + this.#debounceTimer = setTimeout(() => { 312 + this.#search(); 313 + }, 300); 314 + } 315 + 316 + #handleClear() { 317 + this._query = ''; 318 + this._galleries = []; 319 + this._profiles = []; 320 + this._hasMore = false; 321 + this._cursor = null; 322 + } 323 + 324 + #handleTabClick(tab) { 325 + if (this._activeTab === tab) return; 326 + this._activeTab = tab; 327 + this._cursor = null; 328 + this._hasMore = false; 329 + if (this._query.length >= 2) { 330 + this.#search(); 331 + } 332 + } 333 + 334 + async #search() { 335 + this._loading = true; 336 + this._cursor = null; 337 + 338 + try { 339 + if (this._activeTab === 'galleries') { 340 + const result = await grainApi.searchGalleries(this._query, { first: 10 }); 341 + this._galleries = result.galleries; 342 + this._hasMore = result.pageInfo.hasNextPage; 343 + this._cursor = result.pageInfo.endCursor; 344 + } else { 345 + const result = await grainApi.searchProfiles(this._query, { first: 20 }); 346 + this._profiles = result.profiles; 347 + this._hasMore = result.pageInfo.hasNextPage; 348 + this._cursor = result.pageInfo.endCursor; 349 + } 350 + } catch (err) { 351 + console.error('Search failed:', err); 352 + } finally { 353 + this._loading = false; 354 + } 355 + } 356 + 357 + async #loadMore() { 358 + if (this._loading || !this._hasMore || this._query.length < 2) return; 359 + 360 + this._loading = true; 361 + 362 + try { 363 + if (this._activeTab === 'galleries') { 364 + const result = await grainApi.searchGalleries(this._query, { 365 + first: 10, 366 + after: this._cursor 367 + }); 368 + this._galleries = [...this._galleries, ...result.galleries]; 369 + this._hasMore = result.pageInfo.hasNextPage; 370 + this._cursor = result.pageInfo.endCursor; 371 + } else { 372 + const result = await grainApi.searchProfiles(this._query, { 373 + first: 20, 374 + after: this._cursor 375 + }); 376 + this._profiles = [...this._profiles, ...result.profiles]; 377 + this._hasMore = result.pageInfo.hasNextPage; 378 + this._cursor = result.pageInfo.endCursor; 379 + } 380 + } catch (err) { 381 + console.error('Load more failed:', err); 382 + } finally { 383 + this._loading = false; 384 + } 385 + } 386 + 387 + #setupInfiniteScroll() { 388 + const sentinel = this.shadowRoot.getElementById('sentinel'); 389 + if (!sentinel) return; 390 + 391 + this.#observer = new IntersectionObserver( 392 + (entries) => { 393 + if (entries[0].isIntersecting) { 394 + this.#loadMore(); 395 + } 396 + }, 397 + { rootMargin: '200px' } 398 + ); 399 + 400 + this.#observer.observe(sentinel); 401 + } 402 + 403 + #renderResults() { 404 + if (this._query.length < 2) { 405 + return html`<p class="empty">Search for galleries or users</p>`; 406 + } 407 + 408 + if (this._activeTab === 'galleries') { 409 + if (!this._loading && this._galleries.length === 0) { 410 + return html`<p class="empty">No galleries found</p>`; 411 + } 412 + return html` 413 + <grain-feed-layout> 414 + ${this._galleries.map(gallery => html` 415 + <grain-gallery-card .gallery=${gallery}></grain-gallery-card> 416 + `)} 417 + </grain-feed-layout> 418 + `; 419 + } else { 420 + if (!this._loading && this._profiles.length === 0) { 421 + return html`<p class="empty">No users found</p>`; 422 + } 423 + return html` 424 + <div class="profiles-list"> 425 + ${this._profiles.map(profile => html` 426 + <grain-profile-card 427 + handle=${profile.handle} 428 + displayName=${profile.displayName} 429 + description=${profile.description} 430 + avatarUrl=${profile.avatarUrl} 431 + ></grain-profile-card> 432 + `)} 433 + </div> 434 + `; 435 + } 436 + } 437 + 438 + render() { 439 + return html` 440 + <div class="search-container"> 441 + <div class="search-input-wrapper"> 442 + <input 443 + type="text" 444 + placeholder="Search for galleries or users" 445 + .value=${this._query} 446 + @input=${this.#handleInput} 447 + > 448 + ${this._query ? html` 449 + <button class="clear-btn" @click=${this.#handleClear}>&times;</button> 450 + ` : ''} 451 + </div> 452 + </div> 453 + 454 + <div class="tabs"> 455 + <button 456 + class="tab ${this._activeTab === 'galleries' ? 'active' : ''}" 457 + @click=${() => this.#handleTabClick('galleries')} 458 + >Galleries</button> 459 + <button 460 + class="tab ${this._activeTab === 'people' ? 'active' : ''}" 461 + @click=${() => this.#handleTabClick('people')} 462 + >People</button> 463 + </div> 464 + 465 + ${this.#renderResults()} 466 + 467 + <div id="sentinel"></div> 468 + 469 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 470 + `; 471 + } 472 + } 473 + 474 + customElements.define('grain-explore', GrainExplore); 475 + ``` 476 + 477 + **Step 2: Commit** 478 + 479 + ```bash 480 + git add src/components/pages/grain-explore.js 481 + git commit -m "feat: create grain-explore page with tabbed search" 482 + ``` 483 + 484 + --- 485 + 486 + ### Task 4: Register /explore route in grain-app.js 487 + 488 + **Files:** 489 + - Modify: `src/components/pages/grain-app.js` 490 + 491 + **Step 1: Add import for grain-explore** 492 + 493 + Add after line 11 (`import './grain-create-gallery.js';`): 494 + 495 + ```javascript 496 + import './grain-explore.js'; 497 + ``` 498 + 499 + **Step 2: Register the route** 500 + 501 + Add after line 40 (`.register('/create', 'grain-create-gallery')`): 502 + 503 + ```javascript 504 + .register('/explore', 'grain-explore') 505 + ``` 506 + 507 + **Step 3: Commit** 508 + 509 + ```bash 510 + git add src/components/pages/grain-app.js 511 + git commit -m "feat: register /explore route" 512 + ``` 513 + 514 + --- 515 + 516 + ### Task 5: Add search button to bottom nav 517 + 518 + **Files:** 519 + - Modify: `src/components/organisms/grain-bottom-nav.js` 520 + 521 + **Step 1: Add isExplore getter** 522 + 523 + Add after line 92 (`get #isOwnProfile`): 524 + 525 + ```javascript 526 + get #isExplore() { 527 + return window.location.pathname === '/explore'; 528 + } 529 + ``` 530 + 531 + **Step 2: Add handleExplore method** 532 + 533 + Add after line 101 (`#handleProfile`): 534 + 535 + ```javascript 536 + #handleExplore() { 537 + router.push('/explore'); 538 + } 539 + ``` 540 + 541 + **Step 3: Add search button to render** 542 + 543 + Replace the render method nav content (lines 133-162) with: 544 + 545 + ```javascript 546 + <nav> 547 + <button 548 + class=${this.#isHome ? 'active' : ''} 549 + @click=${this.#handleHome} 550 + > 551 + <grain-icon name=${this.#isHome ? 'home' : 'homeLine'} size="20"></grain-icon> 552 + </button> 553 + <button 554 + class=${this.#isExplore ? 'active' : ''} 555 + @click=${this.#handleExplore} 556 + > 557 + <grain-icon name=${this.#isExplore ? 'search' : 'searchLine'} size="20"></grain-icon> 558 + </button> 559 + ${this._user ? html` 560 + <button 561 + class="plus" 562 + @click=${this.#handleCreate} 563 + ?disabled=${this._processing} 564 + > 565 + <grain-icon name="plus" size="20"></grain-icon> 566 + </button> 567 + <input 568 + type="file" 569 + id="photo-input" 570 + accept="image/*" 571 + multiple 572 + @change=${this.#handleFilesSelected} 573 + > 574 + ` : ''} 575 + <button 576 + class=${this.#isOwnProfile ? 'active' : ''} 577 + @click=${this.#handleProfile} 578 + > 579 + <grain-icon name=${this.#isOwnProfile ? 'userFilled' : 'user'} size="20"></grain-icon> 580 + </button> 581 + </nav> 582 + ``` 583 + 584 + **Step 4: Commit** 585 + 586 + ```bash 587 + git add src/components/organisms/grain-bottom-nav.js 588 + git commit -m "feat: add search button to bottom nav" 589 + ``` 590 + 591 + --- 592 + 593 + ### Task 6: Manual testing 594 + 595 + **Step 1: Start dev server** 596 + 597 + ```bash 598 + npm run dev 599 + ``` 600 + 601 + **Step 2: Test the feature** 602 + 603 + 1. Verify search icon appears between Home and + in bottom nav 604 + 2. Click search icon, verify navigation to /explore 605 + 3. Verify search icon is filled/active on /explore page 606 + 4. Type "test" in search, verify galleries appear after debounce 607 + 5. Switch to People tab, verify profiles appear 608 + 6. Clear search, verify empty state shows 609 + 7. Scroll down on results, verify infinite scroll loads more 610 + 611 + **Step 3: Final commit if any fixes needed** 612 + 613 + ```bash 614 + git add -A 615 + git commit -m "fix: address explore page issues" 616 + ```