WIP PWA for Grain
0
fork

Configure Feed

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

feat: add grain-profile-following page

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

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

+190
+190
src/components/pages/grain-profile-following.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { router } from '../../router.js'; 3 + import { grainApi } from '../../services/grain-api.js'; 4 + import '../templates/grain-feed-layout.js'; 5 + import '../molecules/grain-profile-card.js'; 6 + import '../atoms/grain-spinner.js'; 7 + import '../atoms/grain-icon.js'; 8 + 9 + export class GrainProfileFollowing extends LitElement { 10 + static properties = { 11 + handle: { type: String }, 12 + _profiles: { state: true }, 13 + _loading: { state: true }, 14 + _hasMore: { state: true }, 15 + _cursor: { state: true }, 16 + _error: { state: true }, 17 + _totalCount: { state: true } 18 + }; 19 + 20 + static styles = css` 21 + :host { 22 + display: block; 23 + } 24 + .header-bar { 25 + display: flex; 26 + align-items: center; 27 + padding: var(--space-sm); 28 + border-bottom: 1px solid var(--color-border); 29 + } 30 + .back-button { 31 + display: flex; 32 + align-items: center; 33 + justify-content: center; 34 + background: none; 35 + border: none; 36 + padding: var(--space-sm); 37 + margin-left: calc(-1 * var(--space-sm)); 38 + cursor: pointer; 39 + color: var(--color-text-primary); 40 + } 41 + .title { 42 + font-size: var(--font-size-md); 43 + font-weight: var(--font-weight-semibold); 44 + margin-left: var(--space-sm); 45 + } 46 + .error { 47 + padding: var(--space-lg); 48 + text-align: center; 49 + color: var(--color-error); 50 + } 51 + .empty { 52 + padding: var(--space-xl); 53 + text-align: center; 54 + color: var(--color-text-secondary); 55 + } 56 + #sentinel { 57 + height: 1px; 58 + } 59 + `; 60 + 61 + #observer = null; 62 + 63 + constructor() { 64 + super(); 65 + this._profiles = []; 66 + this._loading = true; 67 + this._hasMore = true; 68 + this._cursor = null; 69 + this._error = null; 70 + this._totalCount = 0; 71 + } 72 + 73 + connectedCallback() { 74 + super.connectedCallback(); 75 + this.#loadInitial(); 76 + } 77 + 78 + disconnectedCallback() { 79 + super.disconnectedCallback(); 80 + this.#observer?.disconnect(); 81 + } 82 + 83 + firstUpdated() { 84 + this.#setupInfiniteScroll(); 85 + } 86 + 87 + updated(changedProperties) { 88 + if (changedProperties.has('handle') && this.handle) { 89 + this._profiles = []; 90 + this._cursor = null; 91 + this._hasMore = true; 92 + this.#loadInitial(); 93 + } 94 + } 95 + 96 + async #loadInitial() { 97 + if (!this.handle) return; 98 + 99 + try { 100 + this._loading = true; 101 + this._error = null; 102 + const result = await grainApi.getFollowing(this.handle, { first: 20 }); 103 + 104 + this._profiles = result.profiles; 105 + this._hasMore = result.pageInfo.hasNextPage; 106 + this._cursor = result.pageInfo.endCursor; 107 + this._totalCount = result.totalCount; 108 + } catch (err) { 109 + this._error = err.message; 110 + } finally { 111 + this._loading = false; 112 + } 113 + } 114 + 115 + async #loadMore() { 116 + if (this._loading || !this._hasMore) return; 117 + 118 + try { 119 + this._loading = true; 120 + const result = await grainApi.getFollowing(this.handle, { 121 + first: 20, 122 + after: this._cursor 123 + }); 124 + 125 + this._profiles = [...this._profiles, ...result.profiles]; 126 + this._hasMore = result.pageInfo.hasNextPage; 127 + this._cursor = result.pageInfo.endCursor; 128 + } catch (err) { 129 + this._error = err.message; 130 + } finally { 131 + this._loading = false; 132 + } 133 + } 134 + 135 + #setupInfiniteScroll() { 136 + const sentinel = this.shadowRoot.getElementById('sentinel'); 137 + if (!sentinel) return; 138 + 139 + this.#observer = new IntersectionObserver( 140 + (entries) => { 141 + if (entries[0].isIntersecting) { 142 + this.#loadMore(); 143 + } 144 + }, 145 + { rootMargin: '200px' } 146 + ); 147 + 148 + this.#observer.observe(sentinel); 149 + } 150 + 151 + #handleBack() { 152 + router.push(`/profile/${this.handle}`); 153 + } 154 + 155 + render() { 156 + return html` 157 + <grain-feed-layout> 158 + <div class="header-bar"> 159 + <button class="back-button" @click=${this.#handleBack}> 160 + <grain-icon name="back" size="20"></grain-icon> 161 + </button> 162 + <span class="title">Following${this._totalCount ? ` (${this._totalCount})` : ''}</span> 163 + </div> 164 + 165 + ${this._error ? html` 166 + <p class="error">${this._error}</p> 167 + ` : ''} 168 + 169 + ${this._profiles.map(profile => html` 170 + <grain-profile-card 171 + handle=${profile.handle} 172 + displayName=${profile.displayName} 173 + description=${profile.description} 174 + avatarUrl=${profile.avatarUrl} 175 + ></grain-profile-card> 176 + `)} 177 + 178 + ${!this._loading && !this._profiles.length && !this._error ? html` 179 + <p class="empty">Not following anyone yet</p> 180 + ` : ''} 181 + 182 + <div id="sentinel"></div> 183 + 184 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 185 + </grain-feed-layout> 186 + `; 187 + } 188 + } 189 + 190 + customElements.define('grain-profile-following', GrainProfileFollowing);