WIP PWA for Grain
0
fork

Configure Feed

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

feat: create grain-explore page with tabbed search

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

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

+302
+302
src/components/pages/grain-explore.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { grainApi } from '../../services/grain-api.js'; 3 + import '../templates/grain-feed-layout.js'; 4 + import '../organisms/grain-gallery-card.js'; 5 + import '../molecules/grain-profile-card.js'; 6 + import '../atoms/grain-spinner.js'; 7 + 8 + export class GrainExplore extends LitElement { 9 + static properties = { 10 + _query: { state: true }, 11 + _activeTab: { state: true }, 12 + _galleries: { state: true }, 13 + _profiles: { state: true }, 14 + _loading: { state: true }, 15 + _hasMore: { state: true }, 16 + _cursor: { state: true } 17 + }; 18 + 19 + static styles = css` 20 + :host { 21 + display: block; 22 + } 23 + .search-container { 24 + position: sticky; 25 + top: 48px; 26 + background: var(--color-bg-primary); 27 + padding: var(--space-sm); 28 + z-index: 10; 29 + border-bottom: 1px solid var(--color-border); 30 + } 31 + .search-input-wrapper { 32 + position: relative; 33 + max-width: var(--feed-max-width); 34 + margin: 0 auto; 35 + } 36 + input { 37 + width: 100%; 38 + padding: var(--space-sm) var(--space-md); 39 + padding-right: 40px; 40 + border: 1px solid var(--color-border); 41 + border-radius: 20px; 42 + background: var(--color-bg-secondary); 43 + color: var(--color-text-primary); 44 + font-size: var(--font-size-sm); 45 + box-sizing: border-box; 46 + } 47 + input::placeholder { 48 + color: var(--color-text-secondary); 49 + } 50 + input:focus { 51 + outline: none; 52 + border-color: var(--color-text-secondary); 53 + } 54 + .clear-btn { 55 + position: absolute; 56 + right: 12px; 57 + top: 50%; 58 + transform: translateY(-50%); 59 + background: none; 60 + border: none; 61 + color: var(--color-text-secondary); 62 + cursor: pointer; 63 + padding: 4px; 64 + font-size: 16px; 65 + } 66 + .tabs { 67 + display: flex; 68 + max-width: var(--feed-max-width); 69 + margin: 0 auto; 70 + border-bottom: 1px solid var(--color-border); 71 + } 72 + .tab { 73 + flex: 1; 74 + padding: var(--space-sm) var(--space-md); 75 + background: none; 76 + border: none; 77 + color: var(--color-text-secondary); 78 + font-size: var(--font-size-sm); 79 + cursor: pointer; 80 + border-bottom: 2px solid transparent; 81 + } 82 + .tab.active { 83 + color: var(--color-text-primary); 84 + font-weight: var(--font-weight-semibold); 85 + border-bottom-color: var(--color-text-primary); 86 + } 87 + .empty { 88 + padding: var(--space-xl); 89 + text-align: center; 90 + color: var(--color-text-secondary); 91 + } 92 + .profiles-list { 93 + max-width: var(--feed-max-width); 94 + margin: 0 auto; 95 + padding: var(--space-sm); 96 + } 97 + #sentinel { 98 + height: 1px; 99 + } 100 + `; 101 + 102 + #debounceTimer = null; 103 + #observer = null; 104 + 105 + constructor() { 106 + super(); 107 + this._query = ''; 108 + this._activeTab = 'galleries'; 109 + this._galleries = []; 110 + this._profiles = []; 111 + this._loading = false; 112 + this._hasMore = false; 113 + this._cursor = null; 114 + } 115 + 116 + disconnectedCallback() { 117 + super.disconnectedCallback(); 118 + this.#observer?.disconnect(); 119 + if (this.#debounceTimer) clearTimeout(this.#debounceTimer); 120 + } 121 + 122 + firstUpdated() { 123 + this.#setupInfiniteScroll(); 124 + } 125 + 126 + #handleInput(e) { 127 + const value = e.target.value; 128 + this._query = value; 129 + 130 + if (this.#debounceTimer) clearTimeout(this.#debounceTimer); 131 + 132 + if (value.length < 2) { 133 + this._galleries = []; 134 + this._profiles = []; 135 + this._hasMore = false; 136 + return; 137 + } 138 + 139 + this.#debounceTimer = setTimeout(() => { 140 + this.#search(); 141 + }, 300); 142 + } 143 + 144 + #handleClear() { 145 + this._query = ''; 146 + this._galleries = []; 147 + this._profiles = []; 148 + this._hasMore = false; 149 + this._cursor = null; 150 + } 151 + 152 + #handleTabClick(tab) { 153 + if (this._activeTab === tab) return; 154 + this._activeTab = tab; 155 + this._cursor = null; 156 + this._hasMore = false; 157 + if (this._query.length >= 2) { 158 + this.#search(); 159 + } 160 + } 161 + 162 + async #search() { 163 + this._loading = true; 164 + this._cursor = null; 165 + 166 + try { 167 + if (this._activeTab === 'galleries') { 168 + const result = await grainApi.searchGalleries(this._query, { first: 10 }); 169 + this._galleries = result.galleries; 170 + this._hasMore = result.pageInfo.hasNextPage; 171 + this._cursor = result.pageInfo.endCursor; 172 + } else { 173 + const result = await grainApi.searchProfiles(this._query, { first: 20 }); 174 + this._profiles = result.profiles; 175 + this._hasMore = result.pageInfo.hasNextPage; 176 + this._cursor = result.pageInfo.endCursor; 177 + } 178 + } catch (err) { 179 + console.error('Search failed:', err); 180 + } finally { 181 + this._loading = false; 182 + } 183 + } 184 + 185 + async #loadMore() { 186 + if (this._loading || !this._hasMore || this._query.length < 2) return; 187 + 188 + this._loading = true; 189 + 190 + try { 191 + if (this._activeTab === 'galleries') { 192 + const result = await grainApi.searchGalleries(this._query, { 193 + first: 10, 194 + after: this._cursor 195 + }); 196 + this._galleries = [...this._galleries, ...result.galleries]; 197 + this._hasMore = result.pageInfo.hasNextPage; 198 + this._cursor = result.pageInfo.endCursor; 199 + } else { 200 + const result = await grainApi.searchProfiles(this._query, { 201 + first: 20, 202 + after: this._cursor 203 + }); 204 + this._profiles = [...this._profiles, ...result.profiles]; 205 + this._hasMore = result.pageInfo.hasNextPage; 206 + this._cursor = result.pageInfo.endCursor; 207 + } 208 + } catch (err) { 209 + console.error('Load more failed:', err); 210 + } finally { 211 + this._loading = false; 212 + } 213 + } 214 + 215 + #setupInfiniteScroll() { 216 + const sentinel = this.shadowRoot.getElementById('sentinel'); 217 + if (!sentinel) return; 218 + 219 + this.#observer = new IntersectionObserver( 220 + (entries) => { 221 + if (entries[0].isIntersecting) { 222 + this.#loadMore(); 223 + } 224 + }, 225 + { rootMargin: '200px' } 226 + ); 227 + 228 + this.#observer.observe(sentinel); 229 + } 230 + 231 + #renderResults() { 232 + if (this._query.length < 2) { 233 + return html`<p class="empty">Search for galleries or users</p>`; 234 + } 235 + 236 + if (this._activeTab === 'galleries') { 237 + if (!this._loading && this._galleries.length === 0) { 238 + return html`<p class="empty">No galleries found</p>`; 239 + } 240 + return html` 241 + <grain-feed-layout> 242 + ${this._galleries.map(gallery => html` 243 + <grain-gallery-card .gallery=${gallery}></grain-gallery-card> 244 + `)} 245 + </grain-feed-layout> 246 + `; 247 + } else { 248 + if (!this._loading && this._profiles.length === 0) { 249 + return html`<p class="empty">No users found</p>`; 250 + } 251 + return html` 252 + <div class="profiles-list"> 253 + ${this._profiles.map(profile => html` 254 + <grain-profile-card 255 + handle=${profile.handle} 256 + displayName=${profile.displayName} 257 + description=${profile.description} 258 + avatarUrl=${profile.avatarUrl} 259 + ></grain-profile-card> 260 + `)} 261 + </div> 262 + `; 263 + } 264 + } 265 + 266 + render() { 267 + return html` 268 + <div class="search-container"> 269 + <div class="search-input-wrapper"> 270 + <input 271 + type="text" 272 + placeholder="Search for galleries or users" 273 + .value=${this._query} 274 + @input=${this.#handleInput} 275 + > 276 + ${this._query ? html` 277 + <button class="clear-btn" @click=${this.#handleClear}>&times;</button> 278 + ` : ''} 279 + </div> 280 + </div> 281 + 282 + <div class="tabs"> 283 + <button 284 + class="tab ${this._activeTab === 'galleries' ? 'active' : ''}" 285 + @click=${() => this.#handleTabClick('galleries')} 286 + >Galleries</button> 287 + <button 288 + class="tab ${this._activeTab === 'people' ? 'active' : ''}" 289 + @click=${() => this.#handleTabClick('people')} 290 + >People</button> 291 + </div> 292 + 293 + ${this.#renderResults()} 294 + 295 + <div id="sentinel"></div> 296 + 297 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 298 + `; 299 + } 300 + } 301 + 302 + customElements.define('grain-explore', GrainExplore);