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