forked from
grain.social/grain-pwa
WIP PWA for Grain
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);