WIP PWA for Grain
0
fork

Configure Feed

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

feat: add grain-timeline page with infinite scroll

+164
+164
src/components/pages/grain-timeline.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 '../atoms/grain-spinner.js'; 6 + 7 + export class GrainTimeline extends LitElement { 8 + static properties = { 9 + _galleries: { state: true }, 10 + _loading: { state: true }, 11 + _hasMore: { state: true }, 12 + _cursor: { state: true }, 13 + _error: { state: true } 14 + }; 15 + 16 + static styles = css` 17 + :host { 18 + display: block; 19 + } 20 + .error { 21 + padding: var(--space-lg); 22 + text-align: center; 23 + color: var(--color-error); 24 + } 25 + .empty { 26 + padding: var(--space-xl); 27 + text-align: center; 28 + color: var(--color-text-secondary); 29 + } 30 + #sentinel { 31 + height: 1px; 32 + } 33 + `; 34 + 35 + #observer = null; 36 + 37 + constructor() { 38 + super(); 39 + this._galleries = []; 40 + this._loading = true; 41 + this._hasMore = true; 42 + this._cursor = null; 43 + this._error = null; 44 + } 45 + 46 + connectedCallback() { 47 + super.connectedCallback(); 48 + this.#loadInitial(); 49 + } 50 + 51 + disconnectedCallback() { 52 + super.disconnectedCallback(); 53 + this.#observer?.disconnect(); 54 + } 55 + 56 + firstUpdated() { 57 + this.#setupInfiniteScroll(); 58 + } 59 + 60 + async #loadInitial() { 61 + try { 62 + this._loading = true; 63 + this._error = null; 64 + const result = await grainApi.getTimeline({ first: 10 }); 65 + 66 + // Fetch photos for all galleries 67 + await this.#loadPhotosForGalleries(result.galleries); 68 + 69 + this._galleries = result.galleries; 70 + this._hasMore = result.pageInfo.hasNextPage; 71 + this._cursor = result.pageInfo.endCursor; 72 + } catch (err) { 73 + this._error = err.message; 74 + } finally { 75 + this._loading = false; 76 + } 77 + } 78 + 79 + async #loadMore() { 80 + if (this._loading || !this._hasMore) return; 81 + 82 + try { 83 + this._loading = true; 84 + const result = await grainApi.getTimeline({ 85 + first: 10, 86 + after: this._cursor 87 + }); 88 + 89 + await this.#loadPhotosForGalleries(result.galleries); 90 + 91 + this._galleries = [...this._galleries, ...result.galleries]; 92 + this._hasMore = result.pageInfo.hasNextPage; 93 + this._cursor = result.pageInfo.endCursor; 94 + } catch (err) { 95 + this._error = err.message; 96 + } finally { 97 + this._loading = false; 98 + } 99 + } 100 + 101 + async #loadPhotosForGalleries(galleries) { 102 + const allUris = galleries.flatMap(g => g.photoUris); 103 + if (!allUris.length) return; 104 + 105 + const photos = await grainApi.getPhotosByUris(allUris); 106 + const photoMap = new Map(photos.map(p => [p.uri, p])); 107 + 108 + galleries.forEach(gallery => { 109 + gallery.photos = gallery.photoUris 110 + .map(uri => { 111 + const photo = photoMap.get(uri); 112 + if (!photo) return null; 113 + return { 114 + url: photo.photo?.url || '', 115 + alt: photo.alt || '', 116 + aspectRatio: photo.aspectRatio 117 + ? photo.aspectRatio.width / photo.aspectRatio.height 118 + : 1 119 + }; 120 + }) 121 + .filter(Boolean); 122 + }); 123 + } 124 + 125 + #setupInfiniteScroll() { 126 + const sentinel = this.shadowRoot.getElementById('sentinel'); 127 + if (!sentinel) return; 128 + 129 + this.#observer = new IntersectionObserver( 130 + (entries) => { 131 + if (entries[0].isIntersecting) { 132 + this.#loadMore(); 133 + } 134 + }, 135 + { rootMargin: '200px' } 136 + ); 137 + 138 + this.#observer.observe(sentinel); 139 + } 140 + 141 + render() { 142 + return html` 143 + <grain-feed-layout> 144 + ${this._error ? html` 145 + <p class="error">${this._error}</p> 146 + ` : ''} 147 + 148 + ${this._galleries.map(gallery => html` 149 + <grain-gallery-card .gallery=${gallery}></grain-gallery-card> 150 + `)} 151 + 152 + ${!this._loading && !this._galleries.length && !this._error ? html` 153 + <p class="empty">No galleries yet</p> 154 + ` : ''} 155 + 156 + <div id="sentinel"></div> 157 + 158 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 159 + </grain-feed-layout> 160 + `; 161 + } 162 + } 163 + 164 + customElements.define('grain-timeline', GrainTimeline);