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