docs: add web share API implementation plan

Changed files
+545
docs
+545
docs/plans/2025-12-27-web-share-api.md
··· 1 + # Web Share API Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add share functionality for galleries and profiles using the Web Share API with clipboard fallback. 6 + 7 + **Architecture:** Create a shared utility service for share logic. Add share button to gallery engagement bar. Convert profile header ellipsis to action menu with Share option. Create toast component for clipboard feedback. 8 + 9 + **Tech Stack:** Lit, Web Share API, Clipboard API 10 + 11 + --- 12 + 13 + ## Task 1: Create Share Utility Service 14 + 15 + **Files:** 16 + - Create: `src/services/share.js` 17 + 18 + **Step 1: Create the share service** 19 + 20 + ```javascript 21 + /** 22 + * Share utility with Web Share API and clipboard fallback. 23 + * Returns { success: boolean, method: 'share' | 'clipboard' } 24 + */ 25 + export async function share(url) { 26 + // Try Web Share API first 27 + if (navigator.share) { 28 + try { 29 + await navigator.share({ url }); 30 + return { success: true, method: 'share' }; 31 + } catch (err) { 32 + // User cancelled or error - fall through to clipboard 33 + if (err.name === 'AbortError') { 34 + return { success: false, method: 'share' }; 35 + } 36 + } 37 + } 38 + 39 + // Fallback to clipboard 40 + try { 41 + await navigator.clipboard.writeText(url); 42 + return { success: true, method: 'clipboard' }; 43 + } catch (err) { 44 + console.error('Failed to copy to clipboard:', err); 45 + return { success: false, method: 'clipboard' }; 46 + } 47 + } 48 + ``` 49 + 50 + **Step 2: Commit** 51 + 52 + ```bash 53 + git add src/services/share.js 54 + git commit -m "feat: add share utility service with Web Share API and clipboard fallback" 55 + ``` 56 + 57 + --- 58 + 59 + ## Task 2: Create Toast Component 60 + 61 + **Files:** 62 + - Create: `src/components/atoms/grain-toast.js` 63 + 64 + **Step 1: Create the toast component** 65 + 66 + ```javascript 67 + import { LitElement, html, css } from 'lit'; 68 + 69 + export class GrainToast extends LitElement { 70 + static properties = { 71 + message: { type: String }, 72 + _visible: { state: true } 73 + }; 74 + 75 + static styles = css` 76 + :host { 77 + position: fixed; 78 + bottom: calc(var(--nav-height, 56px) + var(--space-md)); 79 + left: 50%; 80 + transform: translateX(-50%); 81 + z-index: 1001; 82 + pointer-events: none; 83 + } 84 + .toast { 85 + background: var(--color-text-primary); 86 + color: var(--color-bg-primary); 87 + padding: var(--space-sm) var(--space-md); 88 + border-radius: var(--border-radius); 89 + font-size: var(--font-size-sm); 90 + opacity: 0; 91 + transition: opacity 0.2s; 92 + } 93 + .toast.visible { 94 + opacity: 1; 95 + } 96 + `; 97 + 98 + constructor() { 99 + super(); 100 + this.message = ''; 101 + this._visible = false; 102 + } 103 + 104 + show(message, duration = 2000) { 105 + this.message = message; 106 + this._visible = true; 107 + setTimeout(() => { 108 + this._visible = false; 109 + }, duration); 110 + } 111 + 112 + render() { 113 + return html` 114 + <div class="toast ${this._visible ? 'visible' : ''}"> 115 + ${this.message} 116 + </div> 117 + `; 118 + } 119 + } 120 + 121 + customElements.define('grain-toast', GrainToast); 122 + ``` 123 + 124 + **Step 2: Commit** 125 + 126 + ```bash 127 + git add src/components/atoms/grain-toast.js 128 + git commit -m "feat: add toast component for transient feedback" 129 + ``` 130 + 131 + --- 132 + 133 + ## Task 3: Add Share Button to Engagement Bar 134 + 135 + **Files:** 136 + - Modify: `src/components/organisms/grain-engagement-bar.js` 137 + 138 + **Step 1: Add share button and event** 139 + 140 + Update `grain-engagement-bar.js` to: 141 + 1. Add a `url` property for the shareable URL 142 + 2. Import the share utility and toast 143 + 3. Add a share button (using existing `share` icon) 144 + 4. Handle share with toast feedback for clipboard fallback 145 + 146 + ```javascript 147 + import { LitElement, html, css } from 'lit'; 148 + import { share } from '../../services/share.js'; 149 + import '../molecules/grain-stat-count.js'; 150 + import '../atoms/grain-icon.js'; 151 + import '../atoms/grain-toast.js'; 152 + 153 + export class GrainEngagementBar extends LitElement { 154 + static properties = { 155 + favoriteCount: { type: Number }, 156 + commentCount: { type: Number }, 157 + url: { type: String } 158 + }; 159 + 160 + static styles = css` 161 + :host { 162 + display: flex; 163 + align-items: center; 164 + gap: var(--space-sm); 165 + padding: var(--space-sm); 166 + } 167 + @media (min-width: 600px) { 168 + :host { 169 + padding-left: 0; 170 + padding-right: 0; 171 + } 172 + } 173 + .share-button { 174 + display: flex; 175 + align-items: center; 176 + justify-content: center; 177 + background: none; 178 + border: none; 179 + padding: var(--space-sm); 180 + margin: calc(-1 * var(--space-sm)); 181 + cursor: pointer; 182 + color: var(--color-text-primary); 183 + border-radius: var(--border-radius); 184 + transition: opacity 0.2s; 185 + } 186 + .share-button:hover { 187 + opacity: 0.7; 188 + } 189 + .share-button:active { 190 + transform: scale(0.95); 191 + } 192 + `; 193 + 194 + constructor() { 195 + super(); 196 + this.favoriteCount = 0; 197 + this.commentCount = 0; 198 + this.url = ''; 199 + } 200 + 201 + async #handleShare() { 202 + const result = await share(this.url || window.location.href); 203 + if (result.success && result.method === 'clipboard') { 204 + this.shadowRoot.querySelector('grain-toast').show('Link copied'); 205 + } 206 + } 207 + 208 + render() { 209 + return html` 210 + <grain-stat-count 211 + icon="heart" 212 + count=${this.favoriteCount} 213 + ></grain-stat-count> 214 + <grain-stat-count 215 + icon="comment" 216 + count=${this.commentCount} 217 + ></grain-stat-count> 218 + <button class="share-button" type="button" aria-label="Share" @click=${this.#handleShare}> 219 + <grain-icon name="share" size="16"></grain-icon> 220 + </button> 221 + <grain-toast></grain-toast> 222 + `; 223 + } 224 + } 225 + 226 + customElements.define('grain-engagement-bar', GrainEngagementBar); 227 + ``` 228 + 229 + **Step 2: Commit** 230 + 231 + ```bash 232 + git add src/components/organisms/grain-engagement-bar.js 233 + git commit -m "feat: add share button to engagement bar" 234 + ``` 235 + 236 + --- 237 + 238 + ## Task 4: Pass URL to Engagement Bar in Gallery Detail 239 + 240 + **Files:** 241 + - Modify: `src/components/pages/grain-gallery-detail.js` 242 + 243 + **Step 1: Add url property to engagement bar** 244 + 245 + In `grain-gallery-detail.js`, update the `<grain-engagement-bar>` usage to pass the gallery URL: 246 + 247 + Find this line (~259): 248 + ```html 249 + <grain-engagement-bar 250 + favoriteCount=${this._gallery.favoriteCount} 251 + commentCount=${this._gallery.commentCount} 252 + ></grain-engagement-bar> 253 + ``` 254 + 255 + Replace with: 256 + ```html 257 + <grain-engagement-bar 258 + favoriteCount=${this._gallery.favoriteCount} 259 + commentCount=${this._gallery.commentCount} 260 + url=${window.location.href} 261 + ></grain-engagement-bar> 262 + ``` 263 + 264 + **Step 2: Commit** 265 + 266 + ```bash 267 + git add src/components/pages/grain-gallery-detail.js 268 + git commit -m "feat: pass gallery URL to engagement bar for sharing" 269 + ``` 270 + 271 + --- 272 + 273 + ## Task 5: Convert Profile Header Ellipsis to Action Menu 274 + 275 + **Files:** 276 + - Modify: `src/components/organisms/grain-profile-header.js` 277 + 278 + **Step 1: Add action dialog, share logic, and menu state** 279 + 280 + Update `grain-profile-header.js` to: 281 + 1. Import share service, toast, and action dialog 282 + 2. Add state for menu open 283 + 3. Show ellipsis for ALL users (not just owner) 284 + 4. Open action dialog with Share (for everyone) and Settings (for owner only) 285 + 5. Handle share action with toast feedback 286 + 287 + ```javascript 288 + import { LitElement, html, css } from 'lit'; 289 + import { router } from '../../router.js'; 290 + import { auth } from '../../services/auth.js'; 291 + import { share } from '../../services/share.js'; 292 + import '../atoms/grain-avatar.js'; 293 + import '../atoms/grain-icon.js'; 294 + import '../atoms/grain-toast.js'; 295 + import '../molecules/grain-profile-stats.js'; 296 + import '../organisms/grain-action-dialog.js'; 297 + 298 + export class GrainProfileHeader extends LitElement { 299 + static properties = { 300 + profile: { type: Object }, 301 + _showFullscreen: { state: true }, 302 + _user: { state: true }, 303 + _menuOpen: { state: true } 304 + }; 305 + 306 + static styles = css` 307 + :host { 308 + display: block; 309 + padding: var(--space-md) var(--space-sm); 310 + } 311 + @media (min-width: 600px) { 312 + :host { 313 + padding-left: 0; 314 + padding-right: 0; 315 + } 316 + } 317 + .top-row { 318 + display: flex; 319 + align-items: flex-start; 320 + gap: var(--space-md); 321 + margin-bottom: var(--space-sm); 322 + } 323 + .right-column { 324 + flex: 1; 325 + min-width: 0; 326 + padding-top: var(--space-xs); 327 + } 328 + .handle-row { 329 + display: flex; 330 + align-items: center; 331 + gap: var(--space-sm); 332 + margin-bottom: var(--space-xs); 333 + } 334 + .handle { 335 + font-size: var(--font-size-lg); 336 + font-weight: var(--font-weight-semibold); 337 + color: var(--color-text-primary); 338 + } 339 + .name { 340 + font-size: var(--font-size-sm); 341 + color: var(--color-text-primary); 342 + margin-bottom: var(--space-xs); 343 + } 344 + .bio { 345 + font-size: var(--font-size-sm); 346 + color: var(--color-text-secondary); 347 + line-height: 1.4; 348 + white-space: pre-wrap; 349 + margin-top: var(--space-xs); 350 + } 351 + .avatar-button { 352 + background: none; 353 + border: none; 354 + padding: 0; 355 + cursor: pointer; 356 + } 357 + .fullscreen-overlay { 358 + position: fixed; 359 + top: 0; 360 + bottom: 0; 361 + left: 50%; 362 + transform: translateX(-50%); 363 + width: 100%; 364 + max-width: var(--feed-max-width); 365 + background: rgba(0, 0, 0, 0.9); 366 + display: flex; 367 + align-items: center; 368 + justify-content: center; 369 + z-index: 1000; 370 + cursor: pointer; 371 + } 372 + .fullscreen-overlay img { 373 + max-width: 80%; 374 + max-height: 80%; 375 + border-radius: 50%; 376 + object-fit: cover; 377 + } 378 + .menu-button { 379 + background: none; 380 + border: none; 381 + padding: 0; 382 + cursor: pointer; 383 + color: var(--color-text-secondary); 384 + } 385 + `; 386 + 387 + constructor() { 388 + super(); 389 + this._showFullscreen = false; 390 + this._user = auth.user; 391 + this._menuOpen = false; 392 + } 393 + 394 + connectedCallback() { 395 + super.connectedCallback(); 396 + this._unsubscribe = auth.subscribe(user => { 397 + this._user = user; 398 + }); 399 + } 400 + 401 + disconnectedCallback() { 402 + super.disconnectedCallback(); 403 + this._unsubscribe?.(); 404 + } 405 + 406 + get #isOwnProfile() { 407 + return this._user?.handle && this._user.handle === this.profile?.handle; 408 + } 409 + 410 + get #menuActions() { 411 + const actions = [{ label: 'Share', action: 'share' }]; 412 + if (this.#isOwnProfile) { 413 + actions.push({ label: 'Settings', action: 'settings' }); 414 + } 415 + return actions; 416 + } 417 + 418 + #openMenu() { 419 + this._menuOpen = true; 420 + } 421 + 422 + #closeMenu() { 423 + this._menuOpen = false; 424 + } 425 + 426 + async #handleAction(e) { 427 + const action = e.detail.action; 428 + this._menuOpen = false; 429 + 430 + if (action === 'settings') { 431 + router.push('/settings'); 432 + } else if (action === 'share') { 433 + const result = await share(window.location.href); 434 + if (result.success && result.method === 'clipboard') { 435 + this.shadowRoot.querySelector('grain-toast').show('Link copied'); 436 + } 437 + } 438 + } 439 + 440 + #openFullscreen() { 441 + this._showFullscreen = true; 442 + } 443 + 444 + #closeFullscreen() { 445 + this._showFullscreen = false; 446 + } 447 + 448 + render() { 449 + if (!this.profile) return null; 450 + 451 + const { handle, displayName, description, avatarUrl, galleryCount, followerCount, followingCount } = this.profile; 452 + 453 + return html` 454 + ${this._showFullscreen ? html` 455 + <div class="fullscreen-overlay" @click=${this.#closeFullscreen}> 456 + <img src=${avatarUrl || ''} alt=${handle || ''}> 457 + </div> 458 + ` : ''} 459 + 460 + <div class="top-row"> 461 + <button class="avatar-button" @click=${this.#openFullscreen}> 462 + <grain-avatar 463 + src=${avatarUrl || ''} 464 + alt=${handle || ''} 465 + size="lg" 466 + ></grain-avatar> 467 + </button> 468 + <div class="right-column"> 469 + <div class="handle-row"> 470 + <span class="handle">${handle}</span> 471 + <button class="menu-button" @click=${this.#openMenu}> 472 + <grain-icon name="ellipsisVertical" size="20"></grain-icon> 473 + </button> 474 + </div> 475 + ${displayName ? html`<div class="name">${displayName}</div>` : ''} 476 + <grain-profile-stats 477 + handle=${handle} 478 + galleryCount=${galleryCount || 0} 479 + followerCount=${followerCount || 0} 480 + followingCount=${followingCount || 0} 481 + ></grain-profile-stats> 482 + ${description ? html`<div class="bio">${description}</div>` : ''} 483 + </div> 484 + </div> 485 + 486 + <grain-action-dialog 487 + ?open=${this._menuOpen} 488 + .actions=${this.#menuActions} 489 + @action=${this.#handleAction} 490 + @close=${this.#closeMenu} 491 + ></grain-action-dialog> 492 + 493 + <grain-toast></grain-toast> 494 + `; 495 + } 496 + } 497 + 498 + customElements.define('grain-profile-header', GrainProfileHeader); 499 + ``` 500 + 501 + **Step 2: Commit** 502 + 503 + ```bash 504 + git add src/components/organisms/grain-profile-header.js 505 + git commit -m "feat: convert profile ellipsis to action menu with share option" 506 + ``` 507 + 508 + --- 509 + 510 + ## Task 6: Manual Testing 511 + 512 + **Step 1: Test gallery share** 513 + 514 + 1. Navigate to a gallery detail page 515 + 2. Click the share button in the engagement bar 516 + 3. On mobile: Native share sheet should appear 517 + 4. On desktop (no Web Share): "Link copied" toast should appear, verify clipboard contains URL 518 + 519 + **Step 2: Test profile share** 520 + 521 + 1. Navigate to any profile page 522 + 2. Click the ellipsis menu 523 + 3. Verify "Share" option appears for all users 524 + 4. Verify "Settings" option only appears on your own profile 525 + 5. Click "Share" and verify same behavior as gallery share 526 + 527 + **Step 3: Final commit** 528 + 529 + ```bash 530 + git add -A 531 + git commit -m "feat: add web share API for galleries and profiles" 532 + ``` 533 + 534 + --- 535 + 536 + ## Summary 537 + 538 + | Task | Description | 539 + |------|-------------| 540 + | 1 | Create share utility service | 541 + | 2 | Create toast component | 542 + | 3 | Add share button to engagement bar | 543 + | 4 | Pass URL to engagement bar in gallery detail | 544 + | 5 | Convert profile header to action menu with share | 545 + | 6 | Manual testing |