WIP PWA for Grain

feat: add onboarding flow for first-time users

Redirect users to /onboarding after OAuth if they don't have a Grain profile.
The onboarding page prefills with their Bluesky profile data (displayName,
description, avatar). Users can save their profile or skip to continue.

- Add hasGrainProfile and getBlueskyProfile queries to grain-api.js
- Add updateProfile and createEmptyProfile mutations to mutations.js
- Create grain-onboarding.js component with avatar crop support
- Modify OAuth callback to check profile and redirect accordingly

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

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

+1133
+661
docs/plans/2026-01-02-onboarding-flow.md
··· 1 + # Onboarding Flow Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Redirect first-time users to an onboarding page after OAuth, prefilling their Grain profile form with their Bluesky profile data. 6 + 7 + **Architecture:** After OAuth callback, check if `socialGrainActorProfile` exists. If not, redirect to `/onboarding`. The onboarding component fetches `appBskyActorProfile` to prefill displayName, description, and avatar. Users can save or skip; both create a profile record to mark them as onboarded. 8 + 9 + **Tech Stack:** Lit web components, GraphQL via Quickslice client, existing avatar crop component 10 + 11 + --- 12 + 13 + ## Task 1: Add Profile Queries to grain-api.js 14 + 15 + **Files:** 16 + - Modify: `src/services/grain-api.js` 17 + 18 + **Step 1: Add hasGrainProfile method** 19 + 20 + Add this method to the GrainApiService class (after `resolveHandle` method, around line 1123): 21 + 22 + ```javascript 23 + async hasGrainProfile(client) { 24 + const result = await client.query(` 25 + query { 26 + viewer { 27 + socialGrainActorProfileByDid { 28 + displayName 29 + } 30 + } 31 + } 32 + `); 33 + return !!result.viewer?.socialGrainActorProfileByDid; 34 + } 35 + ``` 36 + 37 + **Step 2: Add getBlueskyProfile method** 38 + 39 + Add this method after `hasGrainProfile`: 40 + 41 + ```javascript 42 + async getBlueskyProfile(client) { 43 + const result = await client.query(` 44 + query { 45 + viewer { 46 + did 47 + handle 48 + appBskyActorProfileByDid { 49 + displayName 50 + description 51 + avatar { url ref mimeType size } 52 + } 53 + } 54 + } 55 + `); 56 + 57 + const viewer = result.viewer; 58 + const profile = viewer?.appBskyActorProfileByDid; 59 + const avatar = profile?.avatar; 60 + 61 + return { 62 + did: viewer?.did || '', 63 + handle: viewer?.handle || '', 64 + displayName: profile?.displayName || '', 65 + description: profile?.description || '', 66 + avatarUrl: avatar?.url || '', 67 + avatarBlob: avatar ? { 68 + $type: 'blob', 69 + ref: { $link: avatar.ref }, 70 + mimeType: avatar.mimeType, 71 + size: avatar.size 72 + } : null 73 + }; 74 + } 75 + ``` 76 + 77 + **Step 3: Commit** 78 + 79 + ```bash 80 + git add src/services/grain-api.js 81 + git commit -m "feat: add hasGrainProfile and getBlueskyProfile queries" 82 + ``` 83 + 84 + --- 85 + 86 + ## Task 2: Add Profile Mutations to mutations.js 87 + 88 + **Files:** 89 + - Modify: `src/services/mutations.js` 90 + 91 + **Step 1: Add updateProfile method** 92 + 93 + Add this method to the MutationsService class (after `updateAvatar` method, around line 200): 94 + 95 + ```javascript 96 + async updateProfile(input) { 97 + const client = auth.getClient(); 98 + 99 + await client.mutate(` 100 + mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) { 101 + updateSocialGrainActorProfile(rkey: $rkey, input: $input) { 102 + uri 103 + } 104 + } 105 + `, { rkey: 'self', input }); 106 + 107 + await auth.refreshUser(); 108 + } 109 + 110 + async createEmptyProfile() { 111 + return this.updateProfile({ 112 + displayName: null, 113 + description: null 114 + }); 115 + } 116 + ``` 117 + 118 + **Step 2: Commit** 119 + 120 + ```bash 121 + git add src/services/mutations.js 122 + git commit -m "feat: add updateProfile and createEmptyProfile mutations" 123 + ``` 124 + 125 + --- 126 + 127 + ## Task 3: Register Onboarding Route 128 + 129 + **Files:** 130 + - Modify: `src/components/pages/grain-app.js` 131 + 132 + **Step 1: Add import** 133 + 134 + Add import at line 19 (after `grain-oauth-callback.js`): 135 + 136 + ```javascript 137 + import './grain-onboarding.js'; 138 + ``` 139 + 140 + **Step 2: Add route registration** 141 + 142 + Add route registration at line 69 (before the oauth/callback route): 143 + 144 + ```javascript 145 + .register('/onboarding', 'grain-onboarding') 146 + ``` 147 + 148 + **Step 3: Commit** 149 + 150 + ```bash 151 + git add src/components/pages/grain-app.js 152 + git commit -m "feat: register /onboarding route" 153 + ``` 154 + 155 + --- 156 + 157 + ## Task 4: Create Onboarding Component 158 + 159 + **Files:** 160 + - Create: `src/components/pages/grain-onboarding.js` 161 + 162 + **Step 1: Create the component** 163 + 164 + Create `src/components/pages/grain-onboarding.js`: 165 + 166 + ```javascript 167 + import { LitElement, html, css } from 'lit'; 168 + import { router } from '../../router.js'; 169 + import { auth } from '../../services/auth.js'; 170 + import { grainApi } from '../../services/grain-api.js'; 171 + import { mutations } from '../../services/mutations.js'; 172 + import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 173 + import '../atoms/grain-icon.js'; 174 + import '../atoms/grain-button.js'; 175 + import '../atoms/grain-input.js'; 176 + import '../atoms/grain-textarea.js'; 177 + import '../atoms/grain-avatar.js'; 178 + import '../atoms/grain-spinner.js'; 179 + import '../molecules/grain-form-field.js'; 180 + import '../molecules/grain-avatar-crop.js'; 181 + 182 + export class GrainOnboarding extends LitElement { 183 + static properties = { 184 + _loading: { state: true }, 185 + _saving: { state: true }, 186 + _error: { state: true }, 187 + _displayName: { state: true }, 188 + _description: { state: true }, 189 + _avatarUrl: { state: true }, 190 + _avatarBlob: { state: true }, 191 + _newAvatarDataUrl: { state: true }, 192 + _showAvatarCrop: { state: true }, 193 + _cropImageUrl: { state: true } 194 + }; 195 + 196 + static styles = css` 197 + :host { 198 + display: block; 199 + width: 100%; 200 + max-width: var(--feed-max-width); 201 + min-height: 100%; 202 + padding-bottom: 80px; 203 + background: var(--color-bg-primary); 204 + align-self: center; 205 + } 206 + .header { 207 + display: flex; 208 + flex-direction: column; 209 + align-items: center; 210 + gap: var(--space-xs); 211 + padding: var(--space-xl) var(--space-sm) var(--space-lg); 212 + text-align: center; 213 + } 214 + h1 { 215 + font-size: var(--font-size-xl); 216 + font-weight: var(--font-weight-semibold); 217 + color: var(--color-text-primary); 218 + margin: 0; 219 + } 220 + .subtitle { 221 + font-size: var(--font-size-sm); 222 + color: var(--color-text-secondary); 223 + margin: 0; 224 + } 225 + .content { 226 + padding: 0 var(--space-sm); 227 + } 228 + @media (min-width: 600px) { 229 + .content { 230 + padding: 0; 231 + } 232 + } 233 + .avatar-section { 234 + display: flex; 235 + flex-direction: column; 236 + align-items: center; 237 + margin-bottom: var(--space-lg); 238 + } 239 + .avatar-wrapper { 240 + position: relative; 241 + cursor: pointer; 242 + } 243 + .avatar-overlay { 244 + position: absolute; 245 + bottom: 0; 246 + right: 0; 247 + width: 28px; 248 + height: 28px; 249 + border-radius: 50%; 250 + background: var(--color-bg-primary); 251 + border: 2px solid var(--color-border); 252 + display: flex; 253 + align-items: center; 254 + justify-content: center; 255 + color: var(--color-text-primary); 256 + } 257 + .avatar-preview { 258 + width: 80px; 259 + height: 80px; 260 + border-radius: 50%; 261 + object-fit: cover; 262 + background: var(--color-bg-elevated); 263 + } 264 + input[type="file"] { 265 + display: none; 266 + } 267 + .actions { 268 + display: flex; 269 + flex-direction: column; 270 + gap: var(--space-sm); 271 + padding: var(--space-lg) var(--space-sm); 272 + border-top: 1px solid var(--color-border); 273 + margin-top: var(--space-lg); 274 + } 275 + @media (min-width: 600px) { 276 + .actions { 277 + padding-left: 0; 278 + padding-right: 0; 279 + } 280 + } 281 + .skip-button { 282 + background: none; 283 + border: none; 284 + color: var(--color-text-secondary); 285 + font-size: var(--font-size-sm); 286 + cursor: pointer; 287 + padding: var(--space-sm); 288 + text-align: center; 289 + } 290 + .skip-button:hover { 291 + color: var(--color-text-primary); 292 + text-decoration: underline; 293 + } 294 + .error { 295 + color: var(--color-danger, #dc3545); 296 + font-size: var(--font-size-sm); 297 + padding: var(--space-sm); 298 + text-align: center; 299 + } 300 + .loading { 301 + display: flex; 302 + flex-direction: column; 303 + align-items: center; 304 + justify-content: center; 305 + gap: var(--space-md); 306 + padding: var(--space-xl); 307 + min-height: 300px; 308 + } 309 + .loading p { 310 + color: var(--color-text-secondary); 311 + font-size: var(--font-size-sm); 312 + } 313 + `; 314 + 315 + constructor() { 316 + super(); 317 + this._loading = true; 318 + this._saving = false; 319 + this._error = null; 320 + this._displayName = ''; 321 + this._description = ''; 322 + this._avatarUrl = ''; 323 + this._avatarBlob = null; 324 + this._newAvatarDataUrl = null; 325 + this._showAvatarCrop = false; 326 + this._cropImageUrl = null; 327 + } 328 + 329 + async connectedCallback() { 330 + super.connectedCallback(); 331 + 332 + if (!auth.isAuthenticated) { 333 + router.replace('/'); 334 + return; 335 + } 336 + 337 + await this.#checkAndLoad(); 338 + } 339 + 340 + async #checkAndLoad() { 341 + try { 342 + const client = auth.getClient(); 343 + 344 + // Check if user already has a Grain profile 345 + const hasProfile = await grainApi.hasGrainProfile(client); 346 + if (hasProfile) { 347 + this.#redirectToDestination(); 348 + return; 349 + } 350 + 351 + // Fetch Bluesky profile to prefill 352 + const bskyProfile = await grainApi.getBlueskyProfile(client); 353 + this._displayName = bskyProfile.displayName; 354 + this._description = bskyProfile.description; 355 + this._avatarUrl = bskyProfile.avatarUrl; 356 + this._avatarBlob = bskyProfile.avatarBlob; 357 + } catch (err) { 358 + console.error('Failed to load profile data:', err); 359 + } finally { 360 + this._loading = false; 361 + } 362 + } 363 + 364 + #redirectToDestination() { 365 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 366 + sessionStorage.removeItem('oauth_return_url'); 367 + router.replace(returnUrl); 368 + } 369 + 370 + #handleDisplayNameChange(e) { 371 + this._displayName = e.detail.value.slice(0, 64); 372 + } 373 + 374 + #handleDescriptionChange(e) { 375 + this._description = e.detail.value.slice(0, 256); 376 + } 377 + 378 + #handleAvatarClick() { 379 + this.shadowRoot.querySelector('#avatar-input').click(); 380 + } 381 + 382 + async #handleAvatarChange(e) { 383 + const input = e.target; 384 + const file = input.files?.[0]; 385 + if (!file) return; 386 + 387 + input.value = ''; 388 + 389 + try { 390 + const dataUrl = await readFileAsDataURL(file); 391 + const resized = await resizeImage(dataUrl, { 392 + width: 2000, 393 + height: 2000, 394 + maxSize: 900000 395 + }); 396 + this._cropImageUrl = resized.dataUrl; 397 + this._showAvatarCrop = true; 398 + } catch (err) { 399 + console.error('Failed to process avatar:', err); 400 + this._error = 'Failed to process image'; 401 + } 402 + } 403 + 404 + #handleCropCancel() { 405 + this._showAvatarCrop = false; 406 + this._cropImageUrl = null; 407 + } 408 + 409 + #handleCrop(e) { 410 + this._showAvatarCrop = false; 411 + this._cropImageUrl = null; 412 + this._newAvatarDataUrl = e.detail.dataUrl; 413 + this._avatarBlob = null; 414 + } 415 + 416 + get #displayedAvatarUrl() { 417 + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; 418 + return this._avatarUrl; 419 + } 420 + 421 + async #handleSave() { 422 + if (this._saving) return; 423 + 424 + this._saving = true; 425 + this._error = null; 426 + 427 + try { 428 + const input = { 429 + displayName: this._displayName.trim() || null, 430 + description: this._description.trim() || null 431 + }; 432 + 433 + if (this._newAvatarDataUrl) { 434 + const base64Data = this._newAvatarDataUrl.split(',')[1]; 435 + const blob = await mutations.uploadBlob(base64Data, 'image/jpeg'); 436 + input.avatar = { 437 + $type: 'blob', 438 + ref: { $link: blob.ref }, 439 + mimeType: blob.mimeType, 440 + size: blob.size 441 + }; 442 + } else if (this._avatarBlob) { 443 + input.avatar = this._avatarBlob; 444 + } 445 + 446 + await mutations.updateProfile(input); 447 + this.#redirectToDestination(); 448 + } catch (err) { 449 + console.error('Failed to save profile:', err); 450 + this._error = err.message || 'Failed to save profile. Please try again.'; 451 + } finally { 452 + this._saving = false; 453 + } 454 + } 455 + 456 + async #handleSkip() { 457 + if (this._saving) return; 458 + 459 + this._saving = true; 460 + this._error = null; 461 + 462 + try { 463 + await mutations.createEmptyProfile(); 464 + this.#redirectToDestination(); 465 + } catch (err) { 466 + console.error('Failed to skip onboarding:', err); 467 + this._error = err.message || 'Something went wrong. Please try again.'; 468 + } finally { 469 + this._saving = false; 470 + } 471 + } 472 + 473 + render() { 474 + if (this._loading) { 475 + return html` 476 + <div class="loading"> 477 + <grain-spinner size="32"></grain-spinner> 478 + <p>Loading...</p> 479 + </div> 480 + `; 481 + } 482 + 483 + return html` 484 + <div class="header"> 485 + <h1>Welcome to Grain</h1> 486 + <p class="subtitle">Set up your profile to get started</p> 487 + </div> 488 + 489 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 490 + 491 + <div class="content"> 492 + <div class="avatar-section"> 493 + <div class="avatar-wrapper" @click=${this.#handleAvatarClick}> 494 + ${this.#displayedAvatarUrl ? html` 495 + <img class="avatar-preview" src=${this.#displayedAvatarUrl} alt="Profile avatar"> 496 + ` : html` 497 + <grain-avatar size="lg"></grain-avatar> 498 + `} 499 + <div class="avatar-overlay"> 500 + <grain-icon name="camera" size="14"></grain-icon> 501 + </div> 502 + </div> 503 + <input 504 + type="file" 505 + id="avatar-input" 506 + accept="image/png,image/jpeg" 507 + @change=${this.#handleAvatarChange} 508 + > 509 + </div> 510 + 511 + <grain-form-field label="Display Name" .value=${this._displayName} .maxlength=${64}> 512 + <grain-input 513 + placeholder="Display name" 514 + .value=${this._displayName} 515 + @input=${this.#handleDisplayNameChange} 516 + ></grain-input> 517 + </grain-form-field> 518 + 519 + <grain-form-field label="Bio" .value=${this._description} .maxlength=${256}> 520 + <grain-textarea 521 + placeholder="Tell us about yourself" 522 + .value=${this._description} 523 + .maxlength=${256} 524 + @input=${this.#handleDescriptionChange} 525 + ></grain-textarea> 526 + </grain-form-field> 527 + </div> 528 + 529 + <div class="actions"> 530 + <grain-button 531 + variant="primary" 532 + ?loading=${this._saving} 533 + loadingText="Saving..." 534 + @click=${this.#handleSave} 535 + >Save & Continue</grain-button> 536 + <button 537 + class="skip-button" 538 + ?disabled=${this._saving} 539 + @click=${this.#handleSkip} 540 + >Skip for now</button> 541 + </div> 542 + 543 + <grain-avatar-crop 544 + ?open=${this._showAvatarCrop} 545 + image-url=${this._cropImageUrl || ''} 546 + @crop=${this.#handleCrop} 547 + @cancel=${this.#handleCropCancel} 548 + ></grain-avatar-crop> 549 + `; 550 + } 551 + } 552 + 553 + customElements.define('grain-onboarding', GrainOnboarding); 554 + ``` 555 + 556 + **Step 2: Verify the component compiles** 557 + 558 + Run: `npm run dev` 559 + Navigate to: `http://localhost:5173/onboarding` 560 + Expected: Page loads (redirects to home if not authenticated or already has profile) 561 + 562 + **Step 3: Commit** 563 + 564 + ```bash 565 + git add src/components/pages/grain-onboarding.js 566 + git commit -m "feat: add onboarding component with Bluesky profile prefill" 567 + ``` 568 + 569 + --- 570 + 571 + ## Task 5: Modify OAuth Callback to Check Profile 572 + 573 + **Files:** 574 + - Modify: `src/services/auth.js` 575 + 576 + **Step 1: Add import for grainApi** 577 + 578 + Add at line 2 (after router import): 579 + 580 + ```javascript 581 + import { grainApi } from './grain-api.js'; 582 + ``` 583 + 584 + **Step 2: Update the redirect callback handler** 585 + 586 + Replace lines 19-26 in `src/services/auth.js` with: 587 + 588 + ```javascript 589 + // Handle OAuth callback if present 590 + if (window.location.search.includes('code=')) { 591 + await this.#client.handleRedirectCallback(); 592 + 593 + // Check if user has a Grain profile 594 + const hasProfile = await grainApi.hasGrainProfile(this.#client); 595 + 596 + if (!hasProfile) { 597 + // First-time user - redirect to onboarding 598 + window.location.replace('/onboarding'); 599 + return; 600 + } 601 + 602 + // Existing user - redirect to their destination 603 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 604 + sessionStorage.removeItem('oauth_return_url'); 605 + window.location.replace(returnUrl); 606 + return; 607 + } 608 + ``` 609 + 610 + **Step 3: Commit** 611 + 612 + ```bash 613 + git add src/services/auth.js 614 + git commit -m "feat: redirect first-time users to onboarding after OAuth" 615 + ``` 616 + 617 + --- 618 + 619 + ## Task 6: Test Complete Flow 620 + 621 + **Files:** 622 + - None (manual testing) 623 + 624 + **Step 1: Test new user flow** 625 + 626 + 1. Clear localStorage/sessionStorage (or use incognito) 627 + 2. Navigate to `/explore` 628 + 3. Click login, authenticate with Bluesky 629 + 4. Expected: Redirected to `/onboarding` with Bluesky profile prefilled 630 + 5. Click "Save & Continue" 631 + 6. Expected: Redirected to `/explore` (original return URL) 632 + 633 + **Step 2: Test skip flow** 634 + 635 + 1. Use a different account without Grain profile 636 + 2. Go through OAuth 637 + 3. On onboarding, click "Skip for now" 638 + 4. Expected: Redirected to return URL, profile record created 639 + 640 + **Step 3: Test returning user flow** 641 + 642 + 1. Log out and log back in with same account 643 + 2. Expected: Goes directly to return URL (no onboarding) 644 + 645 + **Step 4: Test manual onboarding access** 646 + 647 + 1. While logged in with existing profile, navigate to `/onboarding` 648 + 2. Expected: Immediately redirected to home 649 + 650 + --- 651 + 652 + ## Summary 653 + 654 + | Task | Description | Files | 655 + |------|-------------|-------| 656 + | 1 | Add profile queries | `grain-api.js` | 657 + | 2 | Add profile mutations | `mutations.js` | 658 + | 3 | Register route | `grain-app.js` | 659 + | 4 | Create onboarding component | `grain-onboarding.js` (new) | 660 + | 5 | Modify OAuth callback | `auth.js` | 661 + | 6 | Test complete flow | Manual testing |
+2
src/components/pages/grain-app.js
··· 17 17 import './grain-privacy.js'; 18 18 import './grain-copyright.js'; 19 19 import './grain-oauth-callback.js'; 20 + import './grain-onboarding.js'; 20 21 import '../organisms/grain-header.js'; 21 22 import '../organisms/grain-bottom-nav.js'; 22 23 ··· 66 67 .register('/create/descriptions', 'grain-image-descriptions') 67 68 .register('/explore', 'grain-explore') 68 69 .register('/notifications', 'grain-notifications') 70 + .register('/onboarding', 'grain-onboarding') 69 71 .register('/oauth/callback', 'grain-oauth-callback') 70 72 .register('*', 'grain-timeline') 71 73 .connect(outlet);
+391
src/components/pages/grain-onboarding.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { router } from '../../router.js'; 3 + import { auth } from '../../services/auth.js'; 4 + import { grainApi } from '../../services/grain-api.js'; 5 + import { mutations } from '../../services/mutations.js'; 6 + import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; 7 + import '../atoms/grain-icon.js'; 8 + import '../atoms/grain-button.js'; 9 + import '../atoms/grain-input.js'; 10 + import '../atoms/grain-textarea.js'; 11 + import '../atoms/grain-avatar.js'; 12 + import '../atoms/grain-spinner.js'; 13 + import '../molecules/grain-form-field.js'; 14 + import '../molecules/grain-avatar-crop.js'; 15 + 16 + export class GrainOnboarding extends LitElement { 17 + static properties = { 18 + _loading: { state: true }, 19 + _saving: { state: true }, 20 + _error: { state: true }, 21 + _displayName: { state: true }, 22 + _description: { state: true }, 23 + _avatarUrl: { state: true }, 24 + _avatarBlob: { state: true }, 25 + _newAvatarDataUrl: { state: true }, 26 + _showAvatarCrop: { state: true }, 27 + _cropImageUrl: { state: true } 28 + }; 29 + 30 + static styles = css` 31 + :host { 32 + display: block; 33 + width: 100%; 34 + max-width: var(--feed-max-width); 35 + min-height: 100%; 36 + padding-bottom: 80px; 37 + background: var(--color-bg-primary); 38 + align-self: center; 39 + } 40 + .header { 41 + display: flex; 42 + flex-direction: column; 43 + align-items: center; 44 + gap: var(--space-xs); 45 + padding: var(--space-xl) var(--space-sm) var(--space-lg); 46 + text-align: center; 47 + } 48 + h1 { 49 + font-size: var(--font-size-xl); 50 + font-weight: var(--font-weight-semibold); 51 + color: var(--color-text-primary); 52 + margin: 0; 53 + } 54 + .subtitle { 55 + font-size: var(--font-size-sm); 56 + color: var(--color-text-secondary); 57 + margin: 0; 58 + } 59 + .content { 60 + padding: 0 var(--space-sm); 61 + } 62 + @media (min-width: 600px) { 63 + .content { 64 + padding: 0; 65 + } 66 + } 67 + .avatar-section { 68 + display: flex; 69 + flex-direction: column; 70 + align-items: center; 71 + margin-bottom: var(--space-lg); 72 + } 73 + .avatar-wrapper { 74 + position: relative; 75 + cursor: pointer; 76 + } 77 + .avatar-overlay { 78 + position: absolute; 79 + bottom: 0; 80 + right: 0; 81 + width: 28px; 82 + height: 28px; 83 + border-radius: 50%; 84 + background: var(--color-bg-primary); 85 + border: 2px solid var(--color-border); 86 + display: flex; 87 + align-items: center; 88 + justify-content: center; 89 + color: var(--color-text-primary); 90 + } 91 + .avatar-preview { 92 + width: 80px; 93 + height: 80px; 94 + border-radius: 50%; 95 + object-fit: cover; 96 + background: var(--color-bg-elevated); 97 + } 98 + input[type="file"] { 99 + display: none; 100 + } 101 + .actions { 102 + display: flex; 103 + flex-direction: column; 104 + gap: var(--space-sm); 105 + padding: var(--space-lg) var(--space-sm); 106 + border-top: 1px solid var(--color-border); 107 + margin-top: var(--space-lg); 108 + } 109 + @media (min-width: 600px) { 110 + .actions { 111 + padding-left: 0; 112 + padding-right: 0; 113 + } 114 + } 115 + .skip-button { 116 + background: none; 117 + border: none; 118 + color: var(--color-text-secondary); 119 + font-size: var(--font-size-sm); 120 + cursor: pointer; 121 + padding: var(--space-sm); 122 + text-align: center; 123 + } 124 + .skip-button:hover { 125 + color: var(--color-text-primary); 126 + text-decoration: underline; 127 + } 128 + .error { 129 + color: var(--color-danger, #dc3545); 130 + font-size: var(--font-size-sm); 131 + padding: var(--space-sm); 132 + text-align: center; 133 + } 134 + .loading { 135 + display: flex; 136 + flex-direction: column; 137 + align-items: center; 138 + justify-content: center; 139 + gap: var(--space-md); 140 + padding: var(--space-xl); 141 + min-height: 300px; 142 + } 143 + .loading p { 144 + color: var(--color-text-secondary); 145 + font-size: var(--font-size-sm); 146 + } 147 + `; 148 + 149 + constructor() { 150 + super(); 151 + this._loading = true; 152 + this._saving = false; 153 + this._error = null; 154 + this._displayName = ''; 155 + this._description = ''; 156 + this._avatarUrl = ''; 157 + this._avatarBlob = null; 158 + this._newAvatarDataUrl = null; 159 + this._showAvatarCrop = false; 160 + this._cropImageUrl = null; 161 + } 162 + 163 + async connectedCallback() { 164 + super.connectedCallback(); 165 + 166 + if (!auth.isAuthenticated) { 167 + router.replace('/'); 168 + return; 169 + } 170 + 171 + await this.#checkAndLoad(); 172 + } 173 + 174 + async #checkAndLoad() { 175 + try { 176 + const client = auth.getClient(); 177 + 178 + // Check if user already has a Grain profile 179 + const hasProfile = await grainApi.hasGrainProfile(client); 180 + if (hasProfile) { 181 + this.#redirectToDestination(); 182 + return; 183 + } 184 + 185 + // Fetch Bluesky profile to prefill 186 + const bskyProfile = await grainApi.getBlueskyProfile(client); 187 + this._displayName = bskyProfile.displayName; 188 + this._description = bskyProfile.description; 189 + this._avatarUrl = bskyProfile.avatarUrl; 190 + this._avatarBlob = bskyProfile.avatarBlob; 191 + } catch (err) { 192 + console.error('Failed to load profile data:', err); 193 + } finally { 194 + this._loading = false; 195 + } 196 + } 197 + 198 + #redirectToDestination() { 199 + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 200 + sessionStorage.removeItem('oauth_return_url'); 201 + router.replace(returnUrl); 202 + } 203 + 204 + #handleDisplayNameChange(e) { 205 + this._displayName = e.detail.value.slice(0, 64); 206 + } 207 + 208 + #handleDescriptionChange(e) { 209 + this._description = e.detail.value.slice(0, 256); 210 + } 211 + 212 + #handleAvatarClick() { 213 + this.shadowRoot.querySelector('#avatar-input').click(); 214 + } 215 + 216 + async #handleAvatarChange(e) { 217 + const input = e.target; 218 + const file = input.files?.[0]; 219 + if (!file) return; 220 + 221 + input.value = ''; 222 + 223 + try { 224 + const dataUrl = await readFileAsDataURL(file); 225 + const resized = await resizeImage(dataUrl, { 226 + width: 2000, 227 + height: 2000, 228 + maxSize: 900000 229 + }); 230 + this._cropImageUrl = resized.dataUrl; 231 + this._showAvatarCrop = true; 232 + } catch (err) { 233 + console.error('Failed to process avatar:', err); 234 + this._error = 'Failed to process image'; 235 + } 236 + } 237 + 238 + #handleCropCancel() { 239 + this._showAvatarCrop = false; 240 + this._cropImageUrl = null; 241 + } 242 + 243 + #handleCrop(e) { 244 + this._showAvatarCrop = false; 245 + this._cropImageUrl = null; 246 + this._newAvatarDataUrl = e.detail.dataUrl; 247 + this._avatarBlob = null; 248 + } 249 + 250 + get #displayedAvatarUrl() { 251 + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; 252 + return this._avatarUrl; 253 + } 254 + 255 + async #handleSave() { 256 + if (this._saving) return; 257 + 258 + this._saving = true; 259 + this._error = null; 260 + 261 + try { 262 + const input = { 263 + createdAt: new Date().toISOString() 264 + }; 265 + 266 + const displayName = this._displayName.trim(); 267 + const description = this._description.trim(); 268 + if (displayName) input.displayName = displayName; 269 + if (description) input.description = description; 270 + 271 + if (this._newAvatarDataUrl) { 272 + const base64Data = this._newAvatarDataUrl.split(',')[1]; 273 + const blob = await mutations.uploadBlob(base64Data, 'image/jpeg'); 274 + input.avatar = { 275 + $type: 'blob', 276 + ref: { $link: blob.ref }, 277 + mimeType: blob.mimeType, 278 + size: blob.size 279 + }; 280 + } else if (this._avatarBlob) { 281 + input.avatar = this._avatarBlob; 282 + } 283 + 284 + await mutations.updateProfile(input); 285 + this.#redirectToDestination(); 286 + } catch (err) { 287 + console.error('Failed to save profile:', err); 288 + this._error = err.message || 'Failed to save profile. Please try again.'; 289 + } finally { 290 + this._saving = false; 291 + } 292 + } 293 + 294 + async #handleSkip() { 295 + if (this._saving) return; 296 + 297 + this._saving = true; 298 + this._error = null; 299 + 300 + try { 301 + await mutations.createEmptyProfile(); 302 + this.#redirectToDestination(); 303 + } catch (err) { 304 + console.error('Failed to skip onboarding:', err); 305 + this._error = err.message || 'Something went wrong. Please try again.'; 306 + } finally { 307 + this._saving = false; 308 + } 309 + } 310 + 311 + render() { 312 + if (this._loading) { 313 + return html` 314 + <div class="loading"> 315 + <grain-spinner size="32"></grain-spinner> 316 + <p>Loading...</p> 317 + </div> 318 + `; 319 + } 320 + 321 + return html` 322 + <div class="header"> 323 + <h1>Welcome to Grain</h1> 324 + <p class="subtitle">Set up your profile to get started</p> 325 + </div> 326 + 327 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 328 + 329 + <div class="content"> 330 + <div class="avatar-section"> 331 + <div class="avatar-wrapper" @click=${this.#handleAvatarClick}> 332 + ${this.#displayedAvatarUrl ? html` 333 + <img class="avatar-preview" src=${this.#displayedAvatarUrl} alt="Profile avatar"> 334 + ` : html` 335 + <grain-avatar size="lg"></grain-avatar> 336 + `} 337 + <div class="avatar-overlay"> 338 + <grain-icon name="camera" size="14"></grain-icon> 339 + </div> 340 + </div> 341 + <input 342 + type="file" 343 + id="avatar-input" 344 + accept="image/png,image/jpeg" 345 + @change=${this.#handleAvatarChange} 346 + > 347 + </div> 348 + 349 + <grain-form-field label="Display Name" .value=${this._displayName} .maxlength=${64}> 350 + <grain-input 351 + placeholder="Display name" 352 + .value=${this._displayName} 353 + @input=${this.#handleDisplayNameChange} 354 + ></grain-input> 355 + </grain-form-field> 356 + 357 + <grain-form-field label="Bio" .value=${this._description} .maxlength=${256}> 358 + <grain-textarea 359 + placeholder="Tell us about yourself" 360 + .value=${this._description} 361 + .maxlength=${256} 362 + @input=${this.#handleDescriptionChange} 363 + ></grain-textarea> 364 + </grain-form-field> 365 + </div> 366 + 367 + <div class="actions"> 368 + <grain-button 369 + variant="primary" 370 + ?loading=${this._saving} 371 + loadingText="Saving..." 372 + @click=${this.#handleSave} 373 + >Save & Continue</grain-button> 374 + <button 375 + class="skip-button" 376 + ?disabled=${this._saving} 377 + @click=${this.#handleSkip} 378 + >Skip for now</button> 379 + </div> 380 + 381 + <grain-avatar-crop 382 + ?open=${this._showAvatarCrop} 383 + image-url=${this._cropImageUrl || ''} 384 + @crop=${this.#handleCrop} 385 + @cancel=${this.#handleCropCancel} 386 + ></grain-avatar-crop> 387 + `; 388 + } 389 + } 390 + 391 + customElements.define('grain-onboarding', GrainOnboarding);
+12
src/services/auth.js
··· 1 1 import { createQuicksliceClient } from 'quickslice-client-js'; 2 2 import { router } from '../router.js'; 3 + import { grainApi } from './grain-api.js'; 3 4 4 5 class AuthService { 5 6 #client = null; ··· 19 20 // Handle OAuth callback if present 20 21 if (window.location.search.includes('code=')) { 21 22 await this.#client.handleRedirectCallback(); 23 + 24 + // Check if user has a Grain profile 25 + const hasProfile = await grainApi.hasGrainProfile(this.#client); 26 + 27 + if (!hasProfile) { 28 + // First-time user - redirect to onboarding 29 + window.location.replace('/onboarding'); 30 + return; 31 + } 32 + 33 + // Existing user - redirect to their destination 22 34 const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; 23 35 sessionStorage.removeItem('oauth_return_url'); 24 36 window.location.replace(returnUrl);
+47
src/services/grain-api.js
··· 1121 1121 1122 1122 return did; 1123 1123 } 1124 + 1125 + async hasGrainProfile(client) { 1126 + const result = await client.query(` 1127 + query { 1128 + viewer { 1129 + socialGrainActorProfileByDid { 1130 + displayName 1131 + } 1132 + } 1133 + } 1134 + `); 1135 + return !!result.viewer?.socialGrainActorProfileByDid; 1136 + } 1137 + 1138 + async getBlueskyProfile(client) { 1139 + const result = await client.query(` 1140 + query { 1141 + viewer { 1142 + did 1143 + handle 1144 + appBskyActorProfileByDid { 1145 + displayName 1146 + description 1147 + avatar { url ref mimeType size } 1148 + } 1149 + } 1150 + } 1151 + `); 1152 + 1153 + const viewer = result.viewer; 1154 + const profile = viewer?.appBskyActorProfileByDid; 1155 + const avatar = profile?.avatar; 1156 + 1157 + return { 1158 + did: viewer?.did || '', 1159 + handle: viewer?.handle || '', 1160 + displayName: profile?.displayName || '', 1161 + description: profile?.description || '', 1162 + avatarUrl: avatar?.url || '', 1163 + avatarBlob: avatar ? { 1164 + $type: 'blob', 1165 + ref: { $link: avatar.ref }, 1166 + mimeType: avatar.mimeType, 1167 + size: avatar.size 1168 + } : null 1169 + }; 1170 + } 1124 1171 } 1125 1172 1126 1173 export const grainApi = new GrainApiService();
+20
src/services/mutations.js
··· 198 198 // Refresh user data 199 199 await auth.refreshUser(); 200 200 } 201 + 202 + async updateProfile(input) { 203 + const client = auth.getClient(); 204 + 205 + await client.mutate(` 206 + mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) { 207 + updateSocialGrainActorProfile(rkey: $rkey, input: $input) { 208 + uri 209 + } 210 + } 211 + `, { rkey: 'self', input }); 212 + 213 + await auth.refreshUser(); 214 + } 215 + 216 + async createEmptyProfile() { 217 + return this.updateProfile({ 218 + createdAt: new Date().toISOString() 219 + }); 220 + } 201 221 } 202 222 203 223 export const mutations = new MutationsService();