+82
docs/plans/2025-12-29-optimistic-favoriting.md
+82
docs/plans/2025-12-29-optimistic-favoriting.md
···
1
+
# Optimistic Favoriting Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Make the favorite button respond instantly by updating UI before API completes, rolling back on failure.
6
+
7
+
**Architecture:** Store previous state before updating, apply optimistic update immediately, restore on API error with toast notification.
8
+
9
+
**Tech Stack:** Lit, existing mutations service, existing toast component.
10
+
11
+
---
12
+
13
+
### Task 1: Implement Optimistic Update
14
+
15
+
**Files:**
16
+
- Modify: `src/components/organisms/grain-engagement-bar.js:72-92`
17
+
18
+
**Step 1: Replace `#handleFavoriteClick` with optimistic version**
19
+
20
+
Replace the entire `#handleFavoriteClick` method (lines 72-92) with:
21
+
22
+
```javascript
23
+
async #handleFavoriteClick() {
24
+
if (!auth.isAuthenticated || this._loading || !this.galleryUri) return;
25
+
26
+
this._loading = true;
27
+
28
+
// Store previous state for rollback
29
+
const previousState = {
30
+
viewerHasFavorited: this.viewerHasFavorited,
31
+
viewerFavoriteUri: this.viewerFavoriteUri,
32
+
favoriteCount: this.favoriteCount
33
+
};
34
+
35
+
// Optimistic update - apply immediately
36
+
this.viewerHasFavorited = !this.viewerHasFavorited;
37
+
this.favoriteCount += this.viewerHasFavorited ? 1 : -1;
38
+
if (!this.viewerHasFavorited) {
39
+
this.viewerFavoriteUri = null;
40
+
}
41
+
42
+
try {
43
+
const update = await mutations.toggleFavorite(
44
+
this.galleryUri,
45
+
previousState.viewerHasFavorited,
46
+
previousState.viewerFavoriteUri,
47
+
previousState.favoriteCount
48
+
);
49
+
// Update with real URI from server (needed for future deletes)
50
+
this.viewerFavoriteUri = update.viewerFavoriteUri;
51
+
} catch (err) {
52
+
// Rollback on failure
53
+
console.error('Failed to toggle favorite:', err);
54
+
this.viewerHasFavorited = previousState.viewerHasFavorited;
55
+
this.viewerFavoriteUri = previousState.viewerFavoriteUri;
56
+
this.favoriteCount = previousState.favoriteCount;
57
+
this.shadowRoot.querySelector('grain-toast').show('Failed to update');
58
+
} finally {
59
+
this._loading = false;
60
+
}
61
+
}
62
+
```
63
+
64
+
**Step 2: Verify in browser**
65
+
66
+
1. Open the app and navigate to a post
67
+
2. Click the heart - it should fill immediately
68
+
3. Check Network tab - request still goes out
69
+
4. Test error case: disable network, click heart, verify rollback + toast
70
+
71
+
**Step 3: Commit**
72
+
73
+
```bash
74
+
git add src/components/organisms/grain-engagement-bar.js
75
+
git commit -m "feat: add optimistic UI for favoriting"
76
+
```
77
+
78
+
---
79
+
80
+
## Done
81
+
82
+
Single task - the change is small and self-contained.
+265
docs/plans/2025-12-29-scroll-to-top.md
+265
docs/plans/2025-12-29-scroll-to-top.md
···
1
+
# Scroll-to-Top Button Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Add a floating action button that appears when scrolled down, scrolls to top and refreshes feed on click.
6
+
7
+
**Architecture:** New `grain-scroll-to-top` atom component with visibility controlled by parent. Timeline tracks scroll position and handles click to scroll + refresh.
8
+
9
+
**Tech Stack:** Lit, CSS custom properties, existing `grain-icon` component
10
+
11
+
---
12
+
13
+
### Task 1: Add arrow-up icon to grain-icon
14
+
15
+
**Files:**
16
+
- Modify: `src/components/atoms/grain-icon.js:4-26`
17
+
18
+
**Step 1: Add arrowUp to ICONS object**
19
+
20
+
Add after line 8 (`back: 'fa-solid fa-arrow-left',`):
21
+
22
+
```javascript
23
+
arrowUp: 'fa-solid fa-arrow-up',
24
+
```
25
+
26
+
**Step 2: Verify icon renders**
27
+
28
+
Open app in browser, temporarily add `<grain-icon name="arrowUp"></grain-icon>` anywhere to confirm it renders.
29
+
30
+
**Step 3: Commit**
31
+
32
+
```bash
33
+
git add src/components/atoms/grain-icon.js
34
+
git commit -m "feat: add arrowUp icon"
35
+
```
36
+
37
+
---
38
+
39
+
### Task 2: Create grain-scroll-to-top component
40
+
41
+
**Files:**
42
+
- Create: `src/components/atoms/grain-scroll-to-top.js`
43
+
44
+
**Step 1: Create the component file**
45
+
46
+
```javascript
47
+
import { LitElement, html, css } from 'lit';
48
+
import './grain-icon.js';
49
+
50
+
export class GrainScrollToTop extends LitElement {
51
+
static properties = {
52
+
visible: { type: Boolean }
53
+
};
54
+
55
+
static styles = css`
56
+
:host {
57
+
position: fixed;
58
+
bottom: 20px;
59
+
left: 20px;
60
+
z-index: 100;
61
+
}
62
+
button {
63
+
display: flex;
64
+
align-items: center;
65
+
justify-content: center;
66
+
width: 48px;
67
+
height: 48px;
68
+
border-radius: 50%;
69
+
border: 1px solid var(--color-border);
70
+
background: var(--color-surface-secondary);
71
+
color: var(--color-accent);
72
+
cursor: pointer;
73
+
opacity: 0;
74
+
pointer-events: none;
75
+
transition: opacity 0.2s ease-in-out;
76
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
77
+
}
78
+
button.visible {
79
+
opacity: 1;
80
+
pointer-events: auto;
81
+
}
82
+
button:hover {
83
+
filter: brightness(1.1);
84
+
}
85
+
button:active {
86
+
transform: scale(0.95);
87
+
}
88
+
`;
89
+
90
+
constructor() {
91
+
super();
92
+
this.visible = false;
93
+
}
94
+
95
+
#handleClick() {
96
+
this.dispatchEvent(new CustomEvent('scroll-top', {
97
+
bubbles: true,
98
+
composed: true
99
+
}));
100
+
}
101
+
102
+
render() {
103
+
return html`
104
+
<button
105
+
class=${this.visible ? 'visible' : ''}
106
+
@click=${this.#handleClick}
107
+
aria-label="Scroll to top"
108
+
>
109
+
<grain-icon name="arrowUp" size="20"></grain-icon>
110
+
</button>
111
+
`;
112
+
}
113
+
}
114
+
115
+
customElements.define('grain-scroll-to-top', GrainScrollToTop);
116
+
```
117
+
118
+
**Step 2: Commit**
119
+
120
+
```bash
121
+
git add src/components/atoms/grain-scroll-to-top.js
122
+
git commit -m "feat: add grain-scroll-to-top component"
123
+
```
124
+
125
+
---
126
+
127
+
### Task 3: Add scroll tracking to grain-timeline
128
+
129
+
**Files:**
130
+
- Modify: `src/components/pages/grain-timeline.js`
131
+
132
+
**Step 1: Add state property for scroll button visibility**
133
+
134
+
Add to static properties (line 13-24):
135
+
136
+
```javascript
137
+
_showScrollTop: { state: true },
138
+
```
139
+
140
+
**Step 2: Initialize state in constructor**
141
+
142
+
Add after line 59 (`this._focusPhotoUrl = null;`):
143
+
144
+
```javascript
145
+
this._showScrollTop = false;
146
+
```
147
+
148
+
**Step 3: Add scroll listener setup/teardown**
149
+
150
+
Add bound handler property after line 46 (`#initialized = false;`):
151
+
152
+
```javascript
153
+
#boundHandleScroll = null;
154
+
```
155
+
156
+
Add to connectedCallback (after line 88):
157
+
158
+
```javascript
159
+
this.#boundHandleScroll = this.#handleScroll.bind(this);
160
+
window.addEventListener('scroll', this.#boundHandleScroll, { passive: true });
161
+
```
162
+
163
+
Add to disconnectedCallback (after line 93):
164
+
165
+
```javascript
166
+
if (this.#boundHandleScroll) {
167
+
window.removeEventListener('scroll', this.#boundHandleScroll);
168
+
}
169
+
```
170
+
171
+
**Step 4: Add scroll handler method**
172
+
173
+
Add after `#handleCommentSheetClose()` method (after line 194):
174
+
175
+
```javascript
176
+
#handleScroll() {
177
+
this._showScrollTop = window.scrollY > 150;
178
+
}
179
+
```
180
+
181
+
**Step 5: Commit**
182
+
183
+
```bash
184
+
git add src/components/pages/grain-timeline.js
185
+
git commit -m "feat: add scroll position tracking to timeline"
186
+
```
187
+
188
+
---
189
+
190
+
### Task 4: Add scroll-to-top button and handler to timeline
191
+
192
+
**Files:**
193
+
- Modify: `src/components/pages/grain-timeline.js`
194
+
195
+
**Step 1: Import the component**
196
+
197
+
Add after line 10 (`import '../atoms/grain-spinner.js';`):
198
+
199
+
```javascript
200
+
import '../atoms/grain-scroll-to-top.js';
201
+
```
202
+
203
+
**Step 2: Add click handler method**
204
+
205
+
Add after `#handleScroll()` method:
206
+
207
+
```javascript
208
+
async #handleScrollTop() {
209
+
if (this._refreshing) return;
210
+
211
+
window.scrollTo({ top: 0, behavior: 'smooth' });
212
+
213
+
// Wait for scroll to complete before refreshing
214
+
await new Promise(resolve => setTimeout(resolve, 400));
215
+
216
+
await this.#handleRefresh();
217
+
}
218
+
```
219
+
220
+
**Step 3: Add component to render**
221
+
222
+
Add after the closing `</grain-feed-layout>` tag (before line 229's closing backtick), outside the feed-layout:
223
+
224
+
```javascript
225
+
<grain-scroll-to-top
226
+
?visible=${this._showScrollTop}
227
+
@scroll-top=${this.#handleScrollTop}
228
+
></grain-scroll-to-top>
229
+
```
230
+
231
+
**Step 4: Commit**
232
+
233
+
```bash
234
+
git add src/components/pages/grain-timeline.js
235
+
git commit -m "feat: integrate scroll-to-top button in timeline"
236
+
```
237
+
238
+
---
239
+
240
+
### Task 5: Manual verification
241
+
242
+
**Step 1: Test scroll appearance**
243
+
244
+
1. Open the app timeline
245
+
2. Scroll down past 150px
246
+
3. Verify button fades in at bottom-left
247
+
248
+
**Step 2: Test click behavior**
249
+
250
+
1. Click the button
251
+
2. Verify smooth scroll to top
252
+
3. Verify feed refreshes (loading spinner appears briefly)
253
+
4. Verify button fades out when at top
254
+
255
+
**Step 3: Test edge cases**
256
+
257
+
1. Scroll down, click button multiple times rapidly - should not double-refresh
258
+
2. Button should not appear when already at top
259
+
260
+
**Step 4: Final commit if any fixes needed**
261
+
262
+
```bash
263
+
git add -A
264
+
git commit -m "fix: scroll-to-top refinements"
265
+
```
+1046
docs/plans/2025-12-30-alt-text-feature.md
+1046
docs/plans/2025-12-30-alt-text-feature.md
···
1
+
# Alt Text Feature Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Add alt text input during gallery creation and display an ALT badge on images that have alt text.
6
+
7
+
**Architecture:** Two-step gallery creation flow (title/description โ image descriptions), plus an ALT badge component that shows alt text in an overlay when clicked.
8
+
9
+
**Tech Stack:** Lit, CSS positioning, existing grain-icon component
10
+
11
+
---
12
+
13
+
### Task 1: Update Draft Gallery Service
14
+
15
+
**Files:**
16
+
- Modify: `src/services/draft-gallery.js`
17
+
18
+
**Step 1: Add updatePhotoAlt method**
19
+
20
+
Update the service to support setting alt text on individual photos:
21
+
22
+
```javascript
23
+
class DraftGalleryService {
24
+
#photos = [];
25
+
26
+
setPhotos(photos) {
27
+
// Ensure each photo has an alt property
28
+
this.#photos = photos.map(p => ({ ...p, alt: p.alt || '' }));
29
+
}
30
+
31
+
getPhotos() {
32
+
return this.#photos;
33
+
}
34
+
35
+
updatePhotoAlt(index, alt) {
36
+
if (index >= 0 && index < this.#photos.length) {
37
+
this.#photos[index] = { ...this.#photos[index], alt };
38
+
}
39
+
}
40
+
41
+
clear() {
42
+
this.#photos = [];
43
+
}
44
+
45
+
get hasPhotos() {
46
+
return this.#photos.length > 0;
47
+
}
48
+
}
49
+
50
+
export const draftGallery = new DraftGalleryService();
51
+
```
52
+
53
+
**Step 2: Commit**
54
+
55
+
```bash
56
+
git add src/services/draft-gallery.js
57
+
git commit -m "feat: add alt text support to draft gallery service"
58
+
```
59
+
60
+
---
61
+
62
+
### Task 2: Create Image Descriptions Page
63
+
64
+
**Files:**
65
+
- Create: `src/components/pages/grain-image-descriptions.js`
66
+
67
+
**Step 1: Create the page component**
68
+
69
+
```javascript
70
+
import { LitElement, html, css } from 'lit';
71
+
import { router } from '../../router.js';
72
+
import { auth } from '../../services/auth.js';
73
+
import { draftGallery } from '../../services/draft-gallery.js';
74
+
import { parseTextToFacets } from '../../lib/richtext.js';
75
+
import { grainApi } from '../../services/grain-api.js';
76
+
import '../atoms/grain-icon.js';
77
+
import '../atoms/grain-button.js';
78
+
79
+
const UPLOAD_BLOB_MUTATION = `
80
+
mutation UploadBlob($data: String!, $mimeType: String!) {
81
+
uploadBlob(data: $data, mimeType: $mimeType) {
82
+
ref
83
+
mimeType
84
+
size
85
+
}
86
+
}
87
+
`;
88
+
89
+
const CREATE_PHOTO_MUTATION = `
90
+
mutation CreatePhoto($input: SocialGrainPhotoInput!) {
91
+
createSocialGrainPhoto(input: $input) {
92
+
uri
93
+
}
94
+
}
95
+
`;
96
+
97
+
const CREATE_GALLERY_MUTATION = `
98
+
mutation CreateGallery($input: SocialGrainGalleryInput!) {
99
+
createSocialGrainGallery(input: $input) {
100
+
uri
101
+
}
102
+
}
103
+
`;
104
+
105
+
const CREATE_GALLERY_ITEM_MUTATION = `
106
+
mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) {
107
+
createSocialGrainGalleryItem(input: $input) {
108
+
uri
109
+
}
110
+
}
111
+
`;
112
+
113
+
export class GrainImageDescriptions extends LitElement {
114
+
static properties = {
115
+
_photos: { state: true },
116
+
_title: { state: true },
117
+
_description: { state: true },
118
+
_posting: { state: true },
119
+
_error: { state: true }
120
+
};
121
+
122
+
static styles = css`
123
+
:host {
124
+
display: block;
125
+
width: 100%;
126
+
max-width: var(--feed-max-width);
127
+
min-height: 100%;
128
+
background: var(--color-bg-primary);
129
+
align-self: center;
130
+
}
131
+
.header {
132
+
display: flex;
133
+
align-items: center;
134
+
justify-content: space-between;
135
+
padding: var(--space-sm);
136
+
border-bottom: 1px solid var(--color-border);
137
+
}
138
+
.header-left {
139
+
display: flex;
140
+
align-items: center;
141
+
gap: var(--space-xs);
142
+
}
143
+
.back-button {
144
+
background: none;
145
+
border: none;
146
+
padding: 8px;
147
+
margin-left: -8px;
148
+
cursor: pointer;
149
+
color: var(--color-text-primary);
150
+
}
151
+
.header-title {
152
+
font-size: var(--font-size-md);
153
+
font-weight: 600;
154
+
}
155
+
.photo-list {
156
+
padding: var(--space-sm);
157
+
}
158
+
.photo-row {
159
+
display: flex;
160
+
gap: var(--space-sm);
161
+
margin-bottom: var(--space-md);
162
+
}
163
+
.photo-thumb {
164
+
flex-shrink: 0;
165
+
width: 80px;
166
+
height: 80px;
167
+
border-radius: 4px;
168
+
object-fit: cover;
169
+
}
170
+
.alt-input {
171
+
flex: 1;
172
+
display: flex;
173
+
flex-direction: column;
174
+
}
175
+
.alt-input textarea {
176
+
flex: 1;
177
+
min-height: 60px;
178
+
padding: var(--space-xs);
179
+
border: 1px solid var(--color-border);
180
+
border-radius: 4px;
181
+
font-family: inherit;
182
+
font-size: var(--font-size-sm);
183
+
resize: none;
184
+
background: var(--color-bg-primary);
185
+
color: var(--color-text-primary);
186
+
}
187
+
.alt-input textarea:focus {
188
+
outline: none;
189
+
border-color: var(--color-accent);
190
+
}
191
+
.alt-input textarea::placeholder {
192
+
color: var(--color-text-tertiary);
193
+
}
194
+
.char-count {
195
+
font-size: var(--font-size-xs);
196
+
color: var(--color-text-tertiary);
197
+
text-align: right;
198
+
margin-top: 4px;
199
+
}
200
+
.error {
201
+
color: #ff4444;
202
+
padding: var(--space-sm);
203
+
text-align: center;
204
+
}
205
+
`;
206
+
207
+
constructor() {
208
+
super();
209
+
this._photos = [];
210
+
this._title = '';
211
+
this._description = '';
212
+
this._posting = false;
213
+
this._error = null;
214
+
}
215
+
216
+
connectedCallback() {
217
+
super.connectedCallback();
218
+
219
+
if (!auth.isAuthenticated) {
220
+
router.replace('/');
221
+
return;
222
+
}
223
+
224
+
this._photos = draftGallery.getPhotos();
225
+
this._title = sessionStorage.getItem('draft_title') || '';
226
+
this._description = sessionStorage.getItem('draft_description') || '';
227
+
228
+
if (!this._photos.length) {
229
+
router.push('/');
230
+
}
231
+
}
232
+
233
+
#handleBack() {
234
+
router.push('/create');
235
+
}
236
+
237
+
#handleAltChange(index, e) {
238
+
const alt = e.target.value.slice(0, 1000);
239
+
draftGallery.updatePhotoAlt(index, alt);
240
+
this._photos = [...draftGallery.getPhotos()];
241
+
}
242
+
243
+
async #handlePost() {
244
+
if (this._posting) return;
245
+
246
+
this._posting = true;
247
+
this._error = null;
248
+
249
+
try {
250
+
const client = auth.getClient();
251
+
const now = new Date().toISOString();
252
+
253
+
const photoUris = [];
254
+
for (const photo of this._photos) {
255
+
const base64Data = photo.dataUrl.split(',')[1];
256
+
const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, {
257
+
data: base64Data,
258
+
mimeType: 'image/jpeg'
259
+
});
260
+
261
+
if (!uploadResult.uploadBlob) {
262
+
throw new Error('Failed to upload image');
263
+
}
264
+
265
+
const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, {
266
+
input: {
267
+
photo: {
268
+
$type: 'blob',
269
+
ref: { $link: uploadResult.uploadBlob.ref },
270
+
mimeType: uploadResult.uploadBlob.mimeType,
271
+
size: uploadResult.uploadBlob.size
272
+
},
273
+
aspectRatio: {
274
+
width: photo.width,
275
+
height: photo.height
276
+
},
277
+
...(photo.alt && { alt: photo.alt }),
278
+
createdAt: now
279
+
}
280
+
});
281
+
282
+
photoUris.push(photoResult.createSocialGrainPhoto.uri);
283
+
}
284
+
285
+
let facets = null;
286
+
if (this._description.trim()) {
287
+
const resolveHandle = async (handle) => grainApi.resolveHandle(handle);
288
+
const parsed = await parseTextToFacets(this._description.trim(), resolveHandle);
289
+
if (parsed.facets.length > 0) {
290
+
facets = parsed.facets;
291
+
}
292
+
}
293
+
294
+
const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, {
295
+
input: {
296
+
title: this._title.trim(),
297
+
...(this._description.trim() && { description: this._description.trim() }),
298
+
...(facets && { facets }),
299
+
createdAt: now
300
+
}
301
+
});
302
+
303
+
const galleryUri = galleryResult.createSocialGrainGallery.uri;
304
+
305
+
for (let i = 0; i < photoUris.length; i++) {
306
+
await client.mutate(CREATE_GALLERY_ITEM_MUTATION, {
307
+
input: {
308
+
gallery: galleryUri,
309
+
item: photoUris[i],
310
+
position: i,
311
+
createdAt: now
312
+
}
313
+
});
314
+
}
315
+
316
+
draftGallery.clear();
317
+
sessionStorage.removeItem('draft_title');
318
+
sessionStorage.removeItem('draft_description');
319
+
const rkey = galleryUri.split('/').pop();
320
+
router.push(`/profile/${auth.user.handle}/gallery/${rkey}`);
321
+
322
+
} catch (err) {
323
+
console.error('Failed to create gallery:', err);
324
+
this._error = err.message || 'Failed to create gallery. Please try again.';
325
+
} finally {
326
+
this._posting = false;
327
+
}
328
+
}
329
+
330
+
render() {
331
+
return html`
332
+
<div class="header">
333
+
<div class="header-left">
334
+
<button class="back-button" @click=${this.#handleBack}>
335
+
<grain-icon name="back" size="20"></grain-icon>
336
+
</button>
337
+
<span class="header-title">Add image descriptions</span>
338
+
</div>
339
+
<grain-button
340
+
?loading=${this._posting}
341
+
loadingText="Posting..."
342
+
@click=${this.#handlePost}
343
+
>Post</grain-button>
344
+
</div>
345
+
346
+
${this._error ? html`<p class="error">${this._error}</p>` : ''}
347
+
348
+
<div class="photo-list">
349
+
${this._photos.map((photo, i) => html`
350
+
<div class="photo-row">
351
+
<img class="photo-thumb" src=${photo.dataUrl} alt="Photo ${i + 1}">
352
+
<div class="alt-input">
353
+
<textarea
354
+
placeholder="Describe this image for people who can't see it"
355
+
.value=${photo.alt || ''}
356
+
@input=${(e) => this.#handleAltChange(i, e)}
357
+
></textarea>
358
+
<span class="char-count">${(photo.alt || '').length}/1000</span>
359
+
</div>
360
+
</div>
361
+
`)}
362
+
</div>
363
+
`;
364
+
}
365
+
}
366
+
367
+
customElements.define('grain-image-descriptions', GrainImageDescriptions);
368
+
```
369
+
370
+
**Step 2: Commit**
371
+
372
+
```bash
373
+
git add src/components/pages/grain-image-descriptions.js
374
+
git commit -m "feat: add image descriptions page for alt text entry"
375
+
```
376
+
377
+
---
378
+
379
+
### Task 3: Update Create Gallery Page
380
+
381
+
**Files:**
382
+
- Modify: `src/components/pages/grain-create-gallery.js`
383
+
384
+
**Step 1: Change Post button to Next and navigate to descriptions page**
385
+
386
+
Remove the posting logic (moved to descriptions page) and update the button:
387
+
388
+
Replace the `#handlePost` method with `#handleNext`:
389
+
390
+
```javascript
391
+
#handleNext() {
392
+
if (!this.#canProceed) return;
393
+
394
+
// Save title/description to sessionStorage for the next page
395
+
sessionStorage.setItem('draft_title', this._title);
396
+
sessionStorage.setItem('draft_description', this._description);
397
+
398
+
// Update draft with current photos (in case any were removed)
399
+
draftGallery.setPhotos(this._photos);
400
+
401
+
router.push('/create/descriptions');
402
+
}
403
+
```
404
+
405
+
Update `#canPost` to `#canProceed`:
406
+
407
+
```javascript
408
+
get #canProceed() {
409
+
return this._title.trim().length > 0 && this._photos.length > 0;
410
+
}
411
+
```
412
+
413
+
Remove the `_posting` and `_error` properties and their usage.
414
+
415
+
Remove the mutation constants (UPLOAD_BLOB_MUTATION, CREATE_PHOTO_MUTATION, CREATE_GALLERY_MUTATION, CREATE_GALLERY_ITEM_MUTATION).
416
+
417
+
Remove imports for `parseTextToFacets` and `grainApi`.
418
+
419
+
Update the button in render:
420
+
421
+
```javascript
422
+
<grain-button
423
+
?disabled=${!this.#canProceed}
424
+
@click=${this.#handleNext}
425
+
>Next</grain-button>
426
+
```
427
+
428
+
Remove the error display from render.
429
+
430
+
**Step 2: Full updated file**
431
+
432
+
```javascript
433
+
import { LitElement, html, css } from 'lit';
434
+
import { router } from '../../router.js';
435
+
import { auth } from '../../services/auth.js';
436
+
import { draftGallery } from '../../services/draft-gallery.js';
437
+
import '../atoms/grain-icon.js';
438
+
import '../atoms/grain-button.js';
439
+
import '../atoms/grain-input.js';
440
+
import '../atoms/grain-textarea.js';
441
+
import '../molecules/grain-form-field.js';
442
+
443
+
export class GrainCreateGallery extends LitElement {
444
+
static properties = {
445
+
_photos: { state: true },
446
+
_title: { state: true },
447
+
_description: { state: true }
448
+
};
449
+
450
+
static styles = css`
451
+
:host {
452
+
display: block;
453
+
width: 100%;
454
+
max-width: var(--feed-max-width);
455
+
min-height: 100%;
456
+
background: var(--color-bg-primary);
457
+
align-self: center;
458
+
}
459
+
.header {
460
+
display: flex;
461
+
align-items: center;
462
+
justify-content: space-between;
463
+
padding: var(--space-sm);
464
+
border-bottom: 1px solid var(--color-border);
465
+
}
466
+
.header-left {
467
+
display: flex;
468
+
align-items: center;
469
+
gap: var(--space-xs);
470
+
}
471
+
.back-button {
472
+
background: none;
473
+
border: none;
474
+
padding: 8px;
475
+
margin-left: -8px;
476
+
cursor: pointer;
477
+
color: var(--color-text-primary);
478
+
}
479
+
.header-title {
480
+
font-size: var(--font-size-md);
481
+
font-weight: 600;
482
+
}
483
+
.photo-strip {
484
+
display: flex;
485
+
gap: var(--space-xs);
486
+
padding: var(--space-sm);
487
+
overflow-x: auto;
488
+
border-bottom: 1px solid var(--color-border);
489
+
}
490
+
.photo-thumb {
491
+
position: relative;
492
+
flex-shrink: 0;
493
+
}
494
+
.photo-thumb img {
495
+
width: 80px;
496
+
height: 80px;
497
+
object-fit: cover;
498
+
border-radius: 4px;
499
+
}
500
+
.remove-photo {
501
+
position: absolute;
502
+
top: -6px;
503
+
right: -6px;
504
+
width: 20px;
505
+
height: 20px;
506
+
border-radius: 50%;
507
+
background: var(--color-text-primary);
508
+
color: var(--color-bg-primary);
509
+
border: none;
510
+
cursor: pointer;
511
+
font-size: 12px;
512
+
display: flex;
513
+
align-items: center;
514
+
justify-content: center;
515
+
}
516
+
.form {
517
+
padding: var(--space-sm);
518
+
}
519
+
`;
520
+
521
+
constructor() {
522
+
super();
523
+
this._photos = [];
524
+
this._title = '';
525
+
this._description = '';
526
+
}
527
+
528
+
connectedCallback() {
529
+
super.connectedCallback();
530
+
531
+
if (!auth.isAuthenticated) {
532
+
router.replace('/');
533
+
return;
534
+
}
535
+
536
+
this._photos = draftGallery.getPhotos();
537
+
538
+
// Restore title/description if returning from descriptions page
539
+
this._title = sessionStorage.getItem('draft_title') || '';
540
+
this._description = sessionStorage.getItem('draft_description') || '';
541
+
542
+
if (!this._photos.length) {
543
+
router.push('/');
544
+
}
545
+
}
546
+
547
+
#handleBack() {
548
+
if (confirm('Discard this gallery?')) {
549
+
draftGallery.clear();
550
+
sessionStorage.removeItem('draft_title');
551
+
sessionStorage.removeItem('draft_description');
552
+
history.back();
553
+
}
554
+
}
555
+
556
+
#removePhoto(index) {
557
+
this._photos = this._photos.filter((_, i) => i !== index);
558
+
draftGallery.setPhotos(this._photos);
559
+
if (this._photos.length === 0) {
560
+
draftGallery.clear();
561
+
sessionStorage.removeItem('draft_title');
562
+
sessionStorage.removeItem('draft_description');
563
+
router.push('/');
564
+
}
565
+
}
566
+
567
+
#handleTitleChange(e) {
568
+
this._title = e.detail.value.slice(0, 100);
569
+
}
570
+
571
+
#handleDescriptionChange(e) {
572
+
this._description = e.detail.value.slice(0, 1000);
573
+
}
574
+
575
+
get #canProceed() {
576
+
return this._title.trim().length > 0 && this._photos.length > 0;
577
+
}
578
+
579
+
#handleNext() {
580
+
if (!this.#canProceed) return;
581
+
582
+
sessionStorage.setItem('draft_title', this._title);
583
+
sessionStorage.setItem('draft_description', this._description);
584
+
draftGallery.setPhotos(this._photos);
585
+
586
+
router.push('/create/descriptions');
587
+
}
588
+
589
+
render() {
590
+
return html`
591
+
<div class="header">
592
+
<div class="header-left">
593
+
<button class="back-button" @click=${this.#handleBack}>
594
+
<grain-icon name="back" size="20"></grain-icon>
595
+
</button>
596
+
<span class="header-title">Create a gallery</span>
597
+
</div>
598
+
<grain-button
599
+
?disabled=${!this.#canProceed}
600
+
@click=${this.#handleNext}
601
+
>Next</grain-button>
602
+
</div>
603
+
604
+
<div class="photo-strip">
605
+
${this._photos.map((photo, i) => html`
606
+
<div class="photo-thumb">
607
+
<img src=${photo.dataUrl} alt="Photo ${i + 1}">
608
+
<button class="remove-photo" @click=${() => this.#removePhoto(i)}>x</button>
609
+
</div>
610
+
`)}
611
+
</div>
612
+
613
+
<div class="form">
614
+
<grain-form-field .value=${this._title} .maxlength=${100}>
615
+
<grain-input
616
+
placeholder="Add a title..."
617
+
.value=${this._title}
618
+
@input=${this.#handleTitleChange}
619
+
></grain-input>
620
+
</grain-form-field>
621
+
622
+
<grain-form-field .value=${this._description} .maxlength=${1000}>
623
+
<grain-textarea
624
+
placeholder="Add a description (optional)..."
625
+
.value=${this._description}
626
+
.maxlength=${1000}
627
+
@input=${this.#handleDescriptionChange}
628
+
></grain-textarea>
629
+
</grain-form-field>
630
+
</div>
631
+
`;
632
+
}
633
+
}
634
+
635
+
customElements.define('grain-create-gallery', GrainCreateGallery);
636
+
```
637
+
638
+
**Step 3: Commit**
639
+
640
+
```bash
641
+
git add src/components/pages/grain-create-gallery.js
642
+
git commit -m "refactor: change create gallery to two-step flow with Next button"
643
+
```
644
+
645
+
---
646
+
647
+
### Task 4: Register Route
648
+
649
+
**Files:**
650
+
- Modify: `src/components/pages/grain-app.js`
651
+
652
+
**Step 1: Import the new page component**
653
+
654
+
Add after the other page imports:
655
+
656
+
```javascript
657
+
import './grain-image-descriptions.js';
658
+
```
659
+
660
+
**Step 2: Register the route**
661
+
662
+
Add after `.register('/create', 'grain-create-gallery')`:
663
+
664
+
```javascript
665
+
.register('/create/descriptions', 'grain-image-descriptions')
666
+
```
667
+
668
+
**Step 3: Commit**
669
+
670
+
```bash
671
+
git add src/components/pages/grain-app.js
672
+
git commit -m "feat: add route for image descriptions page"
673
+
```
674
+
675
+
---
676
+
677
+
### Task 5: Create ALT Badge Component
678
+
679
+
**Files:**
680
+
- Create: `src/components/atoms/grain-alt-badge.js`
681
+
682
+
**Step 1: Create the badge component with overlay functionality**
683
+
684
+
```javascript
685
+
import { LitElement, html, css } from 'lit';
686
+
687
+
export class GrainAltBadge extends LitElement {
688
+
static properties = {
689
+
alt: { type: String },
690
+
_showOverlay: { state: true }
691
+
};
692
+
693
+
static styles = css`
694
+
:host {
695
+
position: absolute;
696
+
bottom: 8px;
697
+
right: 8px;
698
+
z-index: 2;
699
+
}
700
+
.badge {
701
+
background: rgba(0, 0, 0, 0.7);
702
+
color: white;
703
+
font-size: 10px;
704
+
font-weight: 600;
705
+
padding: 2px 4px;
706
+
border-radius: 4px;
707
+
cursor: pointer;
708
+
user-select: none;
709
+
}
710
+
.badge:hover {
711
+
background: rgba(0, 0, 0, 0.85);
712
+
}
713
+
.overlay {
714
+
position: fixed;
715
+
bottom: 0;
716
+
left: 0;
717
+
right: 0;
718
+
background: rgba(0, 0, 0, 0.8);
719
+
color: white;
720
+
padding: var(--space-sm);
721
+
font-size: var(--font-size-sm);
722
+
line-height: 1.4;
723
+
max-height: 40vh;
724
+
overflow-y: auto;
725
+
z-index: 100;
726
+
}
727
+
`;
728
+
729
+
constructor() {
730
+
super();
731
+
this.alt = '';
732
+
this._showOverlay = false;
733
+
}
734
+
735
+
#handleClick(e) {
736
+
e.stopPropagation();
737
+
this._showOverlay = !this._showOverlay;
738
+
}
739
+
740
+
#handleOverlayClick(e) {
741
+
e.stopPropagation();
742
+
this._showOverlay = false;
743
+
}
744
+
745
+
render() {
746
+
if (!this.alt) return null;
747
+
748
+
return html`
749
+
<span class="badge" @click=${this.#handleClick}>ALT</span>
750
+
${this._showOverlay ? html`
751
+
<div class="overlay" @click=${this.#handleOverlayClick}>
752
+
${this.alt}
753
+
</div>
754
+
` : ''}
755
+
`;
756
+
}
757
+
}
758
+
759
+
customElements.define('grain-alt-badge', GrainAltBadge);
760
+
```
761
+
762
+
**Step 2: Commit**
763
+
764
+
```bash
765
+
git add src/components/atoms/grain-alt-badge.js
766
+
git commit -m "feat: add ALT badge component with overlay"
767
+
```
768
+
769
+
---
770
+
771
+
### Task 6: Add ALT Badge to Carousel
772
+
773
+
**Files:**
774
+
- Modify: `src/components/organisms/grain-image-carousel.js`
775
+
776
+
**Step 1: Import the badge component**
777
+
778
+
Add after the other imports:
779
+
780
+
```javascript
781
+
import '../atoms/grain-alt-badge.js';
782
+
```
783
+
784
+
**Step 2: Add styles for slide positioning**
785
+
786
+
Add to the `.slide` rule to enable absolute positioning of badge:
787
+
788
+
```css
789
+
.slide {
790
+
flex: 0 0 100%;
791
+
scroll-snap-align: start;
792
+
position: relative;
793
+
}
794
+
```
795
+
796
+
**Step 3: Add badge to each slide**
797
+
798
+
Update the slide rendering to include the badge:
799
+
800
+
```javascript
801
+
${this.photos.map((photo, index) => html`
802
+
<div class="slide ${hasPortrait ? 'centered' : ''}">
803
+
<grain-image
804
+
src=${this.#shouldLoad(index) ? photo.url : ''}
805
+
alt=${photo.alt || ''}
806
+
aspectRatio=${photo.aspectRatio || 1}
807
+
style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''}
808
+
></grain-image>
809
+
${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''}
810
+
</div>
811
+
`)}
812
+
```
813
+
814
+
**Step 4: Full updated file**
815
+
816
+
```javascript
817
+
import { LitElement, html, css } from 'lit';
818
+
import '../atoms/grain-image.js';
819
+
import '../atoms/grain-icon.js';
820
+
import '../atoms/grain-alt-badge.js';
821
+
import '../molecules/grain-carousel-dots.js';
822
+
823
+
export class GrainImageCarousel extends LitElement {
824
+
static properties = {
825
+
photos: { type: Array },
826
+
rkey: { type: String },
827
+
_currentIndex: { state: true }
828
+
};
829
+
830
+
static styles = css`
831
+
:host {
832
+
display: block;
833
+
position: relative;
834
+
}
835
+
.carousel {
836
+
display: flex;
837
+
overflow-x: auto;
838
+
scroll-snap-type: x mandatory;
839
+
scrollbar-width: none;
840
+
-ms-overflow-style: none;
841
+
}
842
+
.carousel::-webkit-scrollbar {
843
+
display: none;
844
+
}
845
+
.slide {
846
+
flex: 0 0 100%;
847
+
scroll-snap-align: start;
848
+
position: relative;
849
+
}
850
+
.slide.centered {
851
+
display: flex;
852
+
align-items: center;
853
+
justify-content: center;
854
+
}
855
+
.slide.centered grain-image {
856
+
width: 100%;
857
+
}
858
+
.dots {
859
+
position: absolute;
860
+
bottom: 0;
861
+
left: 0;
862
+
right: 0;
863
+
}
864
+
.nav-arrow {
865
+
position: absolute;
866
+
top: 50%;
867
+
transform: translateY(-50%);
868
+
width: 24px;
869
+
height: 24px;
870
+
border-radius: 50%;
871
+
border: none;
872
+
background: rgba(255, 255, 255, 0.7);
873
+
color: rgba(120, 100, 90, 1);
874
+
cursor: pointer;
875
+
display: flex;
876
+
align-items: center;
877
+
justify-content: center;
878
+
padding: 0;
879
+
z-index: 1;
880
+
}
881
+
.nav-arrow:hover {
882
+
background: rgba(255, 255, 255, 1);
883
+
}
884
+
.nav-arrow:focus {
885
+
outline: none;
886
+
}
887
+
.nav-arrow:focus-visible {
888
+
outline: 2px solid rgba(120, 100, 90, 0.5);
889
+
outline-offset: 2px;
890
+
}
891
+
.nav-arrow-left {
892
+
left: 8px;
893
+
}
894
+
.nav-arrow-right {
895
+
right: 8px;
896
+
}
897
+
`;
898
+
899
+
constructor() {
900
+
super();
901
+
this.photos = [];
902
+
this._currentIndex = 0;
903
+
}
904
+
905
+
get #hasPortrait() {
906
+
return this.photos.some(photo => (photo.aspectRatio || 1) < 1);
907
+
}
908
+
909
+
get #minAspectRatio() {
910
+
if (!this.photos.length) return 1;
911
+
return Math.min(...this.photos.map(photo => photo.aspectRatio || 1));
912
+
}
913
+
914
+
#handleScroll(e) {
915
+
const carousel = e.target;
916
+
const index = Math.round(carousel.scrollLeft / carousel.offsetWidth);
917
+
if (index !== this._currentIndex) {
918
+
this._currentIndex = index;
919
+
}
920
+
}
921
+
922
+
#goToPrevious(e) {
923
+
e.stopPropagation();
924
+
if (this._currentIndex > 0) {
925
+
const carousel = this.shadowRoot.querySelector('.carousel');
926
+
const slides = carousel.querySelectorAll('.slide');
927
+
slides[this._currentIndex - 1].scrollIntoView({
928
+
behavior: 'smooth',
929
+
block: 'nearest',
930
+
inline: 'start'
931
+
});
932
+
}
933
+
}
934
+
935
+
#goToNext(e) {
936
+
e.stopPropagation();
937
+
if (this._currentIndex < this.photos.length - 1) {
938
+
const carousel = this.shadowRoot.querySelector('.carousel');
939
+
const slides = carousel.querySelectorAll('.slide');
940
+
slides[this._currentIndex + 1].scrollIntoView({
941
+
behavior: 'smooth',
942
+
block: 'nearest',
943
+
inline: 'start'
944
+
});
945
+
}
946
+
}
947
+
948
+
#shouldLoad(index) {
949
+
return Math.abs(index - this._currentIndex) <= 1;
950
+
}
951
+
952
+
getCurrentPhoto() {
953
+
return this.photos[this._currentIndex] || null;
954
+
}
955
+
956
+
render() {
957
+
const hasPortrait = this.#hasPortrait;
958
+
const minAspectRatio = this.#minAspectRatio;
959
+
const carouselStyle = hasPortrait
960
+
? `aspect-ratio: ${minAspectRatio};`
961
+
: '';
962
+
963
+
const showLeftArrow = this.photos.length > 1 && this._currentIndex > 0;
964
+
const showRightArrow = this.photos.length > 1 && this._currentIndex < this.photos.length - 1;
965
+
966
+
return html`
967
+
<div class="carousel" style=${carouselStyle} @scroll=${this.#handleScroll}>
968
+
${this.photos.map((photo, index) => html`
969
+
<div class="slide ${hasPortrait ? 'centered' : ''}">
970
+
<grain-image
971
+
src=${this.#shouldLoad(index) ? photo.url : ''}
972
+
alt=${photo.alt || ''}
973
+
aspectRatio=${photo.aspectRatio || 1}
974
+
style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''}
975
+
></grain-image>
976
+
${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''}
977
+
</div>
978
+
`)}
979
+
</div>
980
+
${showLeftArrow ? html`
981
+
<button class="nav-arrow nav-arrow-left" @click=${this.#goToPrevious} aria-label="Previous image">
982
+
<grain-icon name="chevronLeft" size="12"></grain-icon>
983
+
</button>
984
+
` : ''}
985
+
${showRightArrow ? html`
986
+
<button class="nav-arrow nav-arrow-right" @click=${this.#goToNext} aria-label="Next image">
987
+
<grain-icon name="chevronRight" size="12"></grain-icon>
988
+
</button>
989
+
` : ''}
990
+
${this.photos.length > 1 ? html`
991
+
<div class="dots">
992
+
<grain-carousel-dots
993
+
total=${this.photos.length}
994
+
current=${this._currentIndex}
995
+
></grain-carousel-dots>
996
+
</div>
997
+
` : ''}
998
+
`;
999
+
}
1000
+
}
1001
+
1002
+
customElements.define('grain-image-carousel', GrainImageCarousel);
1003
+
```
1004
+
1005
+
**Step 5: Commit**
1006
+
1007
+
```bash
1008
+
git add src/components/organisms/grain-image-carousel.js
1009
+
git commit -m "feat: add ALT badge to carousel images"
1010
+
```
1011
+
1012
+
---
1013
+
1014
+
### Task 7: Manual Testing
1015
+
1016
+
**Step 1: Test gallery creation flow**
1017
+
1018
+
1. Click + button in nav to select photos
1019
+
2. Enter title and description on first screen
1020
+
3. Click "Next" - should navigate to image descriptions page
1021
+
4. Verify photos appear with text areas
1022
+
5. Add alt text to one or more images
1023
+
6. Click "Post" - should create gallery and navigate to it
1024
+
1025
+
**Step 2: Test ALT badge display**
1026
+
1027
+
1. Navigate to a gallery with alt text (the one you just created)
1028
+
2. Verify "ALT" badge appears in bottom right of images that have alt text
1029
+
3. Click the badge - overlay should appear at bottom with alt text
1030
+
4. Click overlay or elsewhere - overlay should dismiss
1031
+
5. Verify badge does NOT appear on images without alt text
1032
+
1033
+
**Step 3: Test edge cases**
1034
+
1035
+
- Back button on descriptions page returns to create page with data preserved
1036
+
- Back button on create page with "Discard" clears everything
1037
+
- Single image gallery works
1038
+
- Multi-image gallery works
1039
+
- Very long alt text (up to 1000 chars) works
1040
+
1041
+
**Step 4: Final commit if any fixes needed**
1042
+
1043
+
```bash
1044
+
git add -A
1045
+
git commit -m "fix: alt text feature refinements"
1046
+
```
+211
docs/plans/2026-01-02-oauth-callback-route.md
+211
docs/plans/2026-01-02-oauth-callback-route.md
···
1
+
# OAuth Callback Route Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Add a dedicated `/oauth/callback` route so users can log in from any page and return to where they were.
6
+
7
+
**Architecture:** Store the current path in sessionStorage before OAuth redirect, then read it back after the callback completes and navigate there.
8
+
9
+
**Tech Stack:** Lit web components, quickslice-client-js OAuth
10
+
11
+
---
12
+
13
+
### Task 1: Create OAuth Callback Page Component
14
+
15
+
**Files:**
16
+
- Create: `src/components/pages/grain-oauth-callback.js`
17
+
18
+
**Step 1: Create the callback component**
19
+
20
+
```javascript
21
+
import { LitElement, html, css } from 'lit';
22
+
import '../atoms/grain-spinner.js';
23
+
24
+
export class GrainOAuthCallback extends LitElement {
25
+
static styles = css`
26
+
:host {
27
+
display: flex;
28
+
flex-direction: column;
29
+
align-items: center;
30
+
justify-content: center;
31
+
min-height: 100%;
32
+
gap: var(--space-md);
33
+
}
34
+
p {
35
+
color: var(--color-text-secondary);
36
+
font-size: var(--font-size-sm);
37
+
}
38
+
`;
39
+
40
+
render() {
41
+
return html`
42
+
<grain-spinner size="32"></grain-spinner>
43
+
<p>Signing in...</p>
44
+
`;
45
+
}
46
+
}
47
+
48
+
customElements.define('grain-oauth-callback', GrainOAuthCallback);
49
+
```
50
+
51
+
**Step 2: Verify file exists**
52
+
53
+
Run: `cat src/components/pages/grain-oauth-callback.js`
54
+
Expected: File contents match above
55
+
56
+
**Step 3: Commit**
57
+
58
+
```bash
59
+
git add src/components/pages/grain-oauth-callback.js
60
+
git commit -m "feat: add OAuth callback page component"
61
+
```
62
+
63
+
---
64
+
65
+
### Task 2: Register the OAuth Callback Route
66
+
67
+
**Files:**
68
+
- Modify: `src/components/pages/grain-app.js`
69
+
70
+
**Step 1: Add import for callback component**
71
+
72
+
Add after line 17 (after grain-copyright import):
73
+
```javascript
74
+
import './grain-oauth-callback.js';
75
+
```
76
+
77
+
**Step 2: Register the route**
78
+
79
+
Add after line 67 (before the `*` wildcard route):
80
+
```javascript
81
+
.register('/oauth/callback', 'grain-oauth-callback')
82
+
```
83
+
84
+
**Step 3: Verify build passes**
85
+
86
+
Run: `npm run build`
87
+
Expected: Build succeeds
88
+
89
+
**Step 4: Commit**
90
+
91
+
```bash
92
+
git add src/components/pages/grain-app.js
93
+
git commit -m "feat: register /oauth/callback route"
94
+
```
95
+
96
+
---
97
+
98
+
### Task 3: Store Return URL Before OAuth Redirect
99
+
100
+
**Files:**
101
+
- Modify: `src/services/auth.js`
102
+
103
+
**Step 1: Update login method to store return URL**
104
+
105
+
Replace the login method (lines 58-60):
106
+
107
+
```javascript
108
+
async login(handle) {
109
+
sessionStorage.setItem('oauth_return_url', window.location.pathname);
110
+
await this.#client.loginWithRedirect({ handle });
111
+
}
112
+
```
113
+
114
+
**Step 2: Verify build passes**
115
+
116
+
Run: `npm run build`
117
+
Expected: Build succeeds
118
+
119
+
**Step 3: Commit**
120
+
121
+
```bash
122
+
git add src/services/auth.js
123
+
git commit -m "feat: store return URL before OAuth redirect"
124
+
```
125
+
126
+
---
127
+
128
+
### Task 4: Navigate to Return URL After OAuth Callback
129
+
130
+
**Files:**
131
+
- Modify: `src/services/auth.js`
132
+
133
+
**Step 1: Add router import at top of file**
134
+
135
+
Add after line 1:
136
+
```javascript
137
+
import { router } from '../router.js';
138
+
```
139
+
140
+
**Step 2: Update callback handling to navigate to return URL**
141
+
142
+
Replace lines 18-22 (the callback handling block):
143
+
144
+
```javascript
145
+
// Handle OAuth callback if present
146
+
if (window.location.search.includes('code=')) {
147
+
await this.#client.handleRedirectCallback();
148
+
const returnUrl = sessionStorage.getItem('oauth_return_url') || '/';
149
+
sessionStorage.removeItem('oauth_return_url');
150
+
router.replace(returnUrl);
151
+
}
152
+
```
153
+
154
+
**Step 3: Verify build passes**
155
+
156
+
Run: `npm run build`
157
+
Expected: Build succeeds
158
+
159
+
**Step 4: Commit**
160
+
161
+
```bash
162
+
git add src/services/auth.js
163
+
git commit -m "feat: navigate to return URL after OAuth callback"
164
+
```
165
+
166
+
---
167
+
168
+
### Task 5: Manual Testing Checklist
169
+
170
+
**Step 1: Start dev server**
171
+
172
+
Run: `npm run dev`
173
+
174
+
**Step 2: Test login from timeline**
175
+
176
+
1. Navigate to `http://localhost:5173/`
177
+
2. Click login, enter handle
178
+
3. Complete OAuth flow
179
+
4. Verify you return to `/`
180
+
181
+
**Step 3: Test login from profile page**
182
+
183
+
1. Navigate to `http://localhost:5173/profile/grain.social`
184
+
2. Click login, enter handle
185
+
3. Complete OAuth flow
186
+
4. Verify you return to `/profile/grain.social`
187
+
188
+
**Step 4: Test login from explore page**
189
+
190
+
1. Navigate to `http://localhost:5173/explore`
191
+
2. Click login, enter handle
192
+
3. Complete OAuth flow
193
+
4. Verify you return to `/explore`
194
+
195
+
**Step 5: Test direct visit to callback**
196
+
197
+
1. Navigate directly to `http://localhost:5173/oauth/callback`
198
+
2. Verify it shows "Signing in..." briefly then redirects to `/`
199
+
200
+
---
201
+
202
+
### Task 6: Final Build Verification
203
+
204
+
**Step 1: Run production build**
205
+
206
+
Run: `npm run build`
207
+
Expected: Build succeeds with no errors
208
+
209
+
**Step 2: Commit any remaining changes**
210
+
211
+
If all tests pass and no changes needed, this task is complete.
+661
docs/plans/2026-01-02-onboarding-flow.md
+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 |
+802
docs/plans/2026-01-03-gallery-reporting-design.md
+802
docs/plans/2026-01-03-gallery-reporting-design.md
···
1
+
# Gallery Reporting Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Add the ability to report galleries via action dialogs on both gallery detail pages and timeline cards.
6
+
7
+
**Architecture:** New `grain-report-dialog` component handles the report flow with 6 reason options. Existing `grain-action-dialog` triggers it. The `mutations.js` service calls the QuickSlice `createReport` mutation.
8
+
9
+
**Tech Stack:** Lit 3, QuickSlice GraphQL API, Vite
10
+
11
+
---
12
+
13
+
## Task 1: Add createReport Mutation
14
+
15
+
**Files:**
16
+
- Modify: `src/services/mutations.js`
17
+
18
+
**Step 1: Add the createReport method**
19
+
20
+
Add this method to the `MutationsService` class in `src/services/mutations.js`:
21
+
22
+
```javascript
23
+
async createReport(subjectUri, reasonType, reason = null) {
24
+
const client = auth.getClient();
25
+
const result = await client.mutate(`
26
+
mutation CreateReport($subjectUri: String!, $reasonType: ReportReasonType!, $reason: String) {
27
+
createReport(subjectUri: $subjectUri, reasonType: $reasonType, reason: $reason) {
28
+
id
29
+
status
30
+
createdAt
31
+
}
32
+
}
33
+
`, { subjectUri, reasonType, reason });
34
+
35
+
return result.createReport;
36
+
}
37
+
```
38
+
39
+
**Step 2: Verify syntax**
40
+
41
+
Run: `npm run build`
42
+
Expected: Build succeeds with no errors
43
+
44
+
**Step 3: Commit**
45
+
46
+
```bash
47
+
git add src/services/mutations.js
48
+
git commit -m "feat: add createReport mutation for gallery reporting"
49
+
```
50
+
51
+
---
52
+
53
+
## Task 2: Create Report Dialog Component
54
+
55
+
**Files:**
56
+
- Create: `src/components/organisms/grain-report-dialog.js`
57
+
58
+
**Step 1: Create the component file**
59
+
60
+
Create `src/components/organisms/grain-report-dialog.js` with this content:
61
+
62
+
```javascript
63
+
import { LitElement, html, css } from 'lit';
64
+
import { mutations } from '../../services/mutations.js';
65
+
import '../atoms/grain-button.js';
66
+
import '../atoms/grain-spinner.js';
67
+
68
+
const REPORT_REASONS = [
69
+
{ type: 'SPAM', label: 'Spam', description: 'Unwanted commercial content or repetitive posts' },
70
+
{ type: 'MISLEADING', label: 'Misleading', description: 'False or deceptive information' },
71
+
{ type: 'SEXUAL', label: 'Sexual content', description: 'Adult or inappropriate imagery' },
72
+
{ type: 'RUDE', label: 'Rude or offensive', description: 'Harassment, hate speech, or bullying' },
73
+
{ type: 'VIOLATION', label: 'Rule violation', description: 'Breaking community guidelines' },
74
+
{ type: 'OTHER', label: 'Other', description: 'Something else not listed above' }
75
+
];
76
+
77
+
export class GrainReportDialog extends LitElement {
78
+
static properties = {
79
+
open: { type: Boolean, reflect: true },
80
+
galleryUri: { type: String },
81
+
_selectedReason: { state: true },
82
+
_details: { state: true },
83
+
_submitting: { state: true },
84
+
_error: { state: true }
85
+
};
86
+
87
+
static styles = css`
88
+
:host {
89
+
display: none;
90
+
}
91
+
:host([open]) {
92
+
display: block;
93
+
}
94
+
.overlay {
95
+
position: fixed;
96
+
inset: 0;
97
+
background: rgba(0, 0, 0, 0.5);
98
+
display: flex;
99
+
align-items: center;
100
+
justify-content: center;
101
+
z-index: 1000;
102
+
padding: var(--space-md);
103
+
}
104
+
.dialog {
105
+
background: var(--color-bg-primary);
106
+
border-radius: 12px;
107
+
width: 100%;
108
+
max-width: 400px;
109
+
max-height: 90vh;
110
+
overflow-y: auto;
111
+
}
112
+
.header {
113
+
padding: 16px;
114
+
border-bottom: 1px solid var(--color-border);
115
+
font-weight: var(--font-weight-semibold);
116
+
font-size: var(--font-size-md);
117
+
}
118
+
.content {
119
+
padding: 16px;
120
+
}
121
+
.reason-card {
122
+
display: block;
123
+
width: 100%;
124
+
padding: 12px;
125
+
margin-bottom: 8px;
126
+
background: var(--color-bg-secondary);
127
+
border: 2px solid var(--color-border);
128
+
border-radius: 8px;
129
+
cursor: pointer;
130
+
text-align: left;
131
+
font-family: inherit;
132
+
}
133
+
.reason-card:hover {
134
+
border-color: var(--color-text-secondary);
135
+
}
136
+
.reason-card.selected {
137
+
border-color: var(--color-accent);
138
+
background: var(--color-bg-primary);
139
+
}
140
+
.reason-label {
141
+
display: flex;
142
+
align-items: center;
143
+
gap: 8px;
144
+
font-size: var(--font-size-sm);
145
+
font-weight: var(--font-weight-medium);
146
+
color: var(--color-text-primary);
147
+
}
148
+
.radio {
149
+
width: 18px;
150
+
height: 18px;
151
+
border: 2px solid var(--color-border);
152
+
border-radius: 50%;
153
+
display: flex;
154
+
align-items: center;
155
+
justify-content: center;
156
+
}
157
+
.reason-card.selected .radio {
158
+
border-color: var(--color-accent);
159
+
}
160
+
.radio-dot {
161
+
width: 10px;
162
+
height: 10px;
163
+
border-radius: 50%;
164
+
background: var(--color-accent);
165
+
display: none;
166
+
}
167
+
.reason-card.selected .radio-dot {
168
+
display: block;
169
+
}
170
+
.reason-description {
171
+
font-size: var(--font-size-xs);
172
+
color: var(--color-text-secondary);
173
+
margin-top: 4px;
174
+
margin-left: 26px;
175
+
}
176
+
.details-section {
177
+
margin-top: 16px;
178
+
}
179
+
.details-label {
180
+
font-size: var(--font-size-sm);
181
+
color: var(--color-text-secondary);
182
+
margin-bottom: 8px;
183
+
}
184
+
.details-textarea {
185
+
width: 100%;
186
+
min-height: 80px;
187
+
padding: 12px;
188
+
border: 1px solid var(--color-border);
189
+
border-radius: 8px;
190
+
font-family: inherit;
191
+
font-size: var(--font-size-sm);
192
+
resize: vertical;
193
+
background: var(--color-bg-secondary);
194
+
color: var(--color-text-primary);
195
+
box-sizing: border-box;
196
+
}
197
+
.details-textarea:focus {
198
+
outline: none;
199
+
border-color: var(--color-accent);
200
+
}
201
+
.char-count {
202
+
font-size: var(--font-size-xs);
203
+
color: var(--color-text-secondary);
204
+
text-align: right;
205
+
margin-top: 4px;
206
+
}
207
+
.error {
208
+
color: var(--color-error, #ff4444);
209
+
font-size: var(--font-size-sm);
210
+
margin-top: 12px;
211
+
padding: 8px 12px;
212
+
background: rgba(255, 68, 68, 0.1);
213
+
border-radius: 6px;
214
+
}
215
+
.footer {
216
+
display: flex;
217
+
gap: 12px;
218
+
padding: 16px;
219
+
border-top: 1px solid var(--color-border);
220
+
}
221
+
.footer button {
222
+
flex: 1;
223
+
padding: 12px 16px;
224
+
border-radius: 8px;
225
+
font-family: inherit;
226
+
font-size: var(--font-size-sm);
227
+
font-weight: var(--font-weight-medium);
228
+
cursor: pointer;
229
+
}
230
+
.cancel-button {
231
+
background: var(--color-bg-secondary);
232
+
border: 1px solid var(--color-border);
233
+
color: var(--color-text-primary);
234
+
}
235
+
.submit-button {
236
+
background: var(--color-accent);
237
+
border: none;
238
+
color: white;
239
+
display: flex;
240
+
align-items: center;
241
+
justify-content: center;
242
+
gap: 8px;
243
+
}
244
+
.submit-button:disabled {
245
+
opacity: 0.5;
246
+
cursor: not-allowed;
247
+
}
248
+
`;
249
+
250
+
constructor() {
251
+
super();
252
+
this.open = false;
253
+
this.galleryUri = '';
254
+
this._selectedReason = null;
255
+
this._details = '';
256
+
this._submitting = false;
257
+
this._error = null;
258
+
}
259
+
260
+
updated(changedProperties) {
261
+
if (changedProperties.has('open') && this.open) {
262
+
this.#reset();
263
+
}
264
+
}
265
+
266
+
#reset() {
267
+
this._selectedReason = null;
268
+
this._details = '';
269
+
this._submitting = false;
270
+
this._error = null;
271
+
}
272
+
273
+
#handleOverlayClick(e) {
274
+
if (e.target.classList.contains('overlay') && !this._submitting) {
275
+
this.#close();
276
+
}
277
+
}
278
+
279
+
#close() {
280
+
this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));
281
+
}
282
+
283
+
#selectReason(type) {
284
+
this._selectedReason = type;
285
+
this._error = null;
286
+
}
287
+
288
+
#handleDetailsInput(e) {
289
+
const value = e.target.value;
290
+
if (value.length <= 300) {
291
+
this._details = value;
292
+
}
293
+
}
294
+
295
+
async #submit() {
296
+
if (!this._selectedReason || this._submitting) return;
297
+
298
+
this._submitting = true;
299
+
this._error = null;
300
+
301
+
try {
302
+
await mutations.createReport(
303
+
this.galleryUri,
304
+
this._selectedReason,
305
+
this._details || null
306
+
);
307
+
308
+
this.dispatchEvent(new CustomEvent('submitted', { bubbles: true, composed: true }));
309
+
this.#close();
310
+
} catch (err) {
311
+
console.error('Failed to submit report:', err);
312
+
this._error = 'Failed to submit report. Please try again.';
313
+
} finally {
314
+
this._submitting = false;
315
+
}
316
+
}
317
+
318
+
render() {
319
+
return html`
320
+
<div class="overlay" @click=${this.#handleOverlayClick}>
321
+
<div class="dialog">
322
+
<div class="header">Report gallery</div>
323
+
324
+
<div class="content">
325
+
${REPORT_REASONS.map(reason => html`
326
+
<button
327
+
class="reason-card ${this._selectedReason === reason.type ? 'selected' : ''}"
328
+
@click=${() => this.#selectReason(reason.type)}
329
+
?disabled=${this._submitting}
330
+
>
331
+
<div class="reason-label">
332
+
<div class="radio">
333
+
<div class="radio-dot"></div>
334
+
</div>
335
+
${reason.label}
336
+
</div>
337
+
<div class="reason-description">${reason.description}</div>
338
+
</button>
339
+
`)}
340
+
341
+
<div class="details-section">
342
+
<div class="details-label">Add details (optional)</div>
343
+
<textarea
344
+
class="details-textarea"
345
+
placeholder="Provide additional context..."
346
+
.value=${this._details}
347
+
@input=${this.#handleDetailsInput}
348
+
?disabled=${this._submitting}
349
+
></textarea>
350
+
<div class="char-count">${this._details.length}/300</div>
351
+
</div>
352
+
353
+
${this._error ? html`
354
+
<div class="error">${this._error}</div>
355
+
` : ''}
356
+
</div>
357
+
358
+
<div class="footer">
359
+
<button
360
+
class="cancel-button"
361
+
@click=${this.#close}
362
+
?disabled=${this._submitting}
363
+
>
364
+
Cancel
365
+
</button>
366
+
<button
367
+
class="submit-button"
368
+
@click=${this.#submit}
369
+
?disabled=${!this._selectedReason || this._submitting}
370
+
>
371
+
${this._submitting ? html`<grain-spinner size="16"></grain-spinner>` : ''}
372
+
Submit
373
+
</button>
374
+
</div>
375
+
</div>
376
+
</div>
377
+
`;
378
+
}
379
+
}
380
+
381
+
customElements.define('grain-report-dialog', GrainReportDialog);
382
+
```
383
+
384
+
**Step 2: Verify syntax**
385
+
386
+
Run: `npm run build`
387
+
Expected: Build succeeds with no errors
388
+
389
+
**Step 3: Commit**
390
+
391
+
```bash
392
+
git add src/components/organisms/grain-report-dialog.js
393
+
git commit -m "feat: add grain-report-dialog component"
394
+
```
395
+
396
+
---
397
+
398
+
## Task 3: Add Report to Gallery Detail Page
399
+
400
+
**Files:**
401
+
- Modify: `src/components/pages/grain-gallery-detail.js`
402
+
403
+
**Step 1: Add import for auth and report dialog**
404
+
405
+
The file already imports `auth`. Add the report dialog import after the action dialog import (around line 15):
406
+
407
+
```javascript
408
+
import '../organisms/grain-report-dialog.js';
409
+
```
410
+
411
+
**Step 2: Add _reportDialogOpen state property**
412
+
413
+
Add to the `static properties` object:
414
+
415
+
```javascript
416
+
_reportDialogOpen: { state: true }
417
+
```
418
+
419
+
**Step 3: Initialize _reportDialogOpen in constructor**
420
+
421
+
Add to the constructor:
422
+
423
+
```javascript
424
+
this._reportDialogOpen = false;
425
+
```
426
+
427
+
**Step 4: Update the #isOwner getter usage - show menu for all authenticated users**
428
+
429
+
Find the menu button conditional (around line 361-365):
430
+
431
+
```javascript
432
+
${this.#isOwner ? html`
433
+
<button class="menu-button" @click=${this.#handleMenuOpen}>
434
+
<grain-icon name="ellipsis" size="20"></grain-icon>
435
+
</button>
436
+
` : ''}
437
+
```
438
+
439
+
Replace with:
440
+
441
+
```javascript
442
+
${auth.isAuthenticated ? html`
443
+
<button class="menu-button" @click=${this.#handleMenuOpen}>
444
+
<grain-icon name="ellipsis" size="20"></grain-icon>
445
+
</button>
446
+
` : ''}
447
+
```
448
+
449
+
**Step 5: Update actions array to show Report for non-owners**
450
+
451
+
Find the grain-action-dialog element (around line 407-414):
452
+
453
+
```javascript
454
+
<grain-action-dialog
455
+
?open=${this._menuOpen}
456
+
?loading=${this._deleting}
457
+
loadingText="Deleting..."
458
+
.actions=${[{ label: 'Delete', action: 'delete', danger: true }]}
459
+
@action=${this.#handleAction}
460
+
@close=${this.#handleMenuClose}
461
+
></grain-action-dialog>
462
+
```
463
+
464
+
Replace with:
465
+
466
+
```javascript
467
+
<grain-action-dialog
468
+
?open=${this._menuOpen}
469
+
?loading=${this._deleting}
470
+
loadingText="Deleting..."
471
+
.actions=${this.#isOwner
472
+
? [{ label: 'Delete', action: 'delete', danger: true }]
473
+
: [{ label: 'Report gallery', action: 'report' }]}
474
+
@action=${this.#handleAction}
475
+
@close=${this.#handleMenuClose}
476
+
></grain-action-dialog>
477
+
```
478
+
479
+
**Step 6: Add report dialog after action dialog**
480
+
481
+
Add after the grain-action-dialog closing tag:
482
+
483
+
```javascript
484
+
<grain-report-dialog
485
+
?open=${this._reportDialogOpen}
486
+
galleryUri=${this._gallery?.uri || ''}
487
+
@close=${this.#handleReportDialogClose}
488
+
@submitted=${this.#handleReportSubmitted}
489
+
></grain-report-dialog>
490
+
```
491
+
492
+
**Step 7: Update #handleAction to handle report action**
493
+
494
+
Find the #handleAction method:
495
+
496
+
```javascript
497
+
async #handleAction(e) {
498
+
if (e.detail.action === 'delete') {
499
+
await this.#handleDelete();
500
+
}
501
+
}
502
+
```
503
+
504
+
Replace with:
505
+
506
+
```javascript
507
+
async #handleAction(e) {
508
+
if (e.detail.action === 'delete') {
509
+
await this.#handleDelete();
510
+
} else if (e.detail.action === 'report') {
511
+
this._menuOpen = false;
512
+
this._reportDialogOpen = true;
513
+
}
514
+
}
515
+
```
516
+
517
+
**Step 8: Add report dialog handlers**
518
+
519
+
Add these methods after `#handleMenuClose`:
520
+
521
+
```javascript
522
+
#handleReportDialogClose() {
523
+
this._reportDialogOpen = false;
524
+
}
525
+
526
+
#handleReportSubmitted() {
527
+
this._reportDialogOpen = false;
528
+
this.dispatchEvent(new CustomEvent('show-toast', {
529
+
bubbles: true,
530
+
composed: true,
531
+
detail: { message: 'Report submitted' }
532
+
}));
533
+
}
534
+
```
535
+
536
+
**Step 9: Verify syntax and test manually**
537
+
538
+
Run: `npm run dev`
539
+
Expected:
540
+
- Navigate to a gallery you don't own
541
+
- Ellipsis menu appears
542
+
- Clicking shows "Report gallery" option
543
+
- Clicking "Report gallery" opens report dialog
544
+
545
+
**Step 10: Commit**
546
+
547
+
```bash
548
+
git add src/components/pages/grain-gallery-detail.js
549
+
git commit -m "feat: add report gallery option to gallery detail page"
550
+
```
551
+
552
+
---
553
+
554
+
## Task 4: Add Action Menu to Gallery Card
555
+
556
+
**Files:**
557
+
- Modify: `src/components/organisms/grain-gallery-card.js`
558
+
559
+
**Step 1: Add imports**
560
+
561
+
Add after existing imports (around line 5):
562
+
563
+
```javascript
564
+
import { auth } from '../../services/auth.js';
565
+
import './grain-action-dialog.js';
566
+
import './grain-report-dialog.js';
567
+
```
568
+
569
+
**Step 2: Add state properties**
570
+
571
+
Add to `static properties`:
572
+
573
+
```javascript
574
+
_menuOpen: { state: true },
575
+
_reportDialogOpen: { state: true },
576
+
_deleting: { state: true }
577
+
```
578
+
579
+
**Step 3: Add CSS for menu button**
580
+
581
+
Add to `static styles` before the closing backtick:
582
+
583
+
```css
584
+
.header-row {
585
+
display: flex;
586
+
align-items: center;
587
+
justify-content: space-between;
588
+
}
589
+
.menu-button {
590
+
display: flex;
591
+
align-items: center;
592
+
justify-content: center;
593
+
background: none;
594
+
border: none;
595
+
padding: var(--space-sm);
596
+
margin-right: calc(-1 * var(--space-sm));
597
+
cursor: pointer;
598
+
color: var(--color-text-primary);
599
+
}
600
+
```
601
+
602
+
**Step 4: Add constructor to initialize state**
603
+
604
+
Add after `static styles`:
605
+
606
+
```javascript
607
+
constructor() {
608
+
super();
609
+
this._menuOpen = false;
610
+
this._reportDialogOpen = false;
611
+
this._deleting = false;
612
+
}
613
+
```
614
+
615
+
**Step 5: Add #isOwner getter**
616
+
617
+
Add after constructor:
618
+
619
+
```javascript
620
+
get #isOwner() {
621
+
return auth.user?.handle === this.gallery?.handle;
622
+
}
623
+
```
624
+
625
+
**Step 6: Update header template**
626
+
627
+
Find the header section in render():
628
+
629
+
```javascript
630
+
<header class="header">
631
+
<grain-author-chip
632
+
avatarUrl=${gallery.avatarUrl || ''}
633
+
handle=${gallery.handle}
634
+
displayName=${gallery.displayName || ''}
635
+
></grain-author-chip>
636
+
</header>
637
+
```
638
+
639
+
Replace with:
640
+
641
+
```javascript
642
+
<header class="header">
643
+
<div class="header-row">
644
+
<grain-author-chip
645
+
avatarUrl=${gallery.avatarUrl || ''}
646
+
handle=${gallery.handle}
647
+
displayName=${gallery.displayName || ''}
648
+
></grain-author-chip>
649
+
${auth.isAuthenticated ? html`
650
+
<button class="menu-button" @click=${this.#handleMenuOpen}>
651
+
<grain-icon name="ellipsis" size="20"></grain-icon>
652
+
</button>
653
+
` : ''}
654
+
</div>
655
+
</header>
656
+
```
657
+
658
+
**Step 7: Add grain-icon import**
659
+
660
+
Add to imports at top:
661
+
662
+
```javascript
663
+
import '../atoms/grain-icon.js';
664
+
```
665
+
666
+
**Step 8: Add dialogs to template**
667
+
668
+
Add before the closing `\`;\` of the render return, after the content div:
669
+
670
+
```javascript
671
+
<grain-action-dialog
672
+
?open=${this._menuOpen}
673
+
?loading=${this._deleting}
674
+
loadingText="Deleting..."
675
+
.actions=${this.#isOwner
676
+
? [{ label: 'Delete', action: 'delete', danger: true }]
677
+
: [{ label: 'Report gallery', action: 'report' }]}
678
+
@action=${this.#handleAction}
679
+
@close=${this.#handleMenuClose}
680
+
></grain-action-dialog>
681
+
682
+
<grain-report-dialog
683
+
?open=${this._reportDialogOpen}
684
+
galleryUri=${gallery.uri || ''}
685
+
@close=${this.#handleReportDialogClose}
686
+
@submitted=${this.#handleReportSubmitted}
687
+
></grain-report-dialog>
688
+
```
689
+
690
+
**Step 9: Add event handler methods**
691
+
692
+
Add before the render() method:
693
+
694
+
```javascript
695
+
#handleMenuOpen(e) {
696
+
e.stopPropagation();
697
+
this._menuOpen = true;
698
+
}
699
+
700
+
#handleMenuClose() {
701
+
this._menuOpen = false;
702
+
}
703
+
704
+
async #handleAction(e) {
705
+
if (e.detail.action === 'delete') {
706
+
await this.#handleDelete();
707
+
} else if (e.detail.action === 'report') {
708
+
this._menuOpen = false;
709
+
this._reportDialogOpen = true;
710
+
}
711
+
}
712
+
713
+
async #handleDelete() {
714
+
this._deleting = true;
715
+
try {
716
+
const client = auth.getClient();
717
+
const rkey = this.#rkey;
718
+
719
+
// Note: Full delete requires fetching gallery details first to get photo/item URIs
720
+
// For now, just delete the gallery record
721
+
await client.mutate(`
722
+
mutation DeleteGallery($rkey: String!) {
723
+
deleteSocialGrainGallery(rkey: $rkey) { uri }
724
+
}
725
+
`, { rkey });
726
+
727
+
// Dispatch event to notify parent to remove from list
728
+
this.dispatchEvent(new CustomEvent('gallery-deleted', {
729
+
bubbles: true,
730
+
composed: true,
731
+
detail: { uri: this.gallery.uri }
732
+
}));
733
+
} catch (err) {
734
+
console.error('Failed to delete gallery:', err);
735
+
} finally {
736
+
this._deleting = false;
737
+
this._menuOpen = false;
738
+
}
739
+
}
740
+
741
+
#handleReportDialogClose() {
742
+
this._reportDialogOpen = false;
743
+
}
744
+
745
+
#handleReportSubmitted() {
746
+
this._reportDialogOpen = false;
747
+
this.dispatchEvent(new CustomEvent('show-toast', {
748
+
bubbles: true,
749
+
composed: true,
750
+
detail: { message: 'Report submitted' }
751
+
}));
752
+
}
753
+
```
754
+
755
+
**Step 10: Verify syntax and test manually**
756
+
757
+
Run: `npm run dev`
758
+
Expected:
759
+
- Timeline shows ellipsis button on each gallery card (when logged in)
760
+
- Clicking shows appropriate action (Delete for own, Report for others)
761
+
- Report flow works from card
762
+
763
+
**Step 11: Commit**
764
+
765
+
```bash
766
+
git add src/components/organisms/grain-gallery-card.js
767
+
git commit -m "feat: add action menu with report option to gallery cards"
768
+
```
769
+
770
+
---
771
+
772
+
## Task 5: Final Build Verification
773
+
774
+
**Step 1: Full build**
775
+
776
+
Run: `npm run build`
777
+
Expected: Build succeeds with no errors
778
+
779
+
**Step 2: Manual end-to-end test**
780
+
781
+
Run: `npm run dev`
782
+
Test these scenarios:
783
+
1. Logged out: No menu buttons visible anywhere
784
+
2. Logged in, own gallery detail: Menu shows "Delete"
785
+
3. Logged in, other's gallery detail: Menu shows "Report gallery"
786
+
4. Logged in, own gallery card: Menu shows "Delete"
787
+
5. Logged in, other's gallery card: Menu shows "Report gallery"
788
+
6. Submit report: Dialog closes, toast shows "Report submitted"
789
+
790
+
**Step 3: Commit any fixes if needed**
791
+
792
+
---
793
+
794
+
## Summary
795
+
796
+
| Task | Files | Description |
797
+
|------|-------|-------------|
798
+
| 1 | mutations.js | Add createReport mutation |
799
+
| 2 | grain-report-dialog.js | Create report dialog component |
800
+
| 3 | grain-gallery-detail.js | Add report option to detail page |
801
+
| 4 | grain-gallery-card.js | Add action menu to cards |
802
+
| 5 | - | Final verification |
+257
docs/plans/2026-01-04-alt-overlay-scroll-fix.md
+257
docs/plans/2026-01-04-alt-overlay-scroll-fix.md
···
1
+
# Alt Text Overlay Scroll Fix
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Fix alt text overlay position drifting when page scrolls by moving overlay rendering from the badge to the carousel.
6
+
7
+
**Architecture:** Move overlay from `grain-alt-badge` (which uses `position: fixed` with JS positioning) to `grain-image-carousel` (which renders it inside the slide with `position: absolute; inset: 0`). The badge becomes a simple button that emits an event.
8
+
9
+
**Tech Stack:** Lit, Web Components, CSS
10
+
11
+
---
12
+
13
+
### Task 1: Simplify grain-alt-badge to emit event
14
+
15
+
**Files:**
16
+
- Modify: `src/components/atoms/grain-alt-badge.js`
17
+
18
+
**Step 1: Remove overlay state and scroll listener logic**
19
+
20
+
Replace the entire file with:
21
+
22
+
```js
23
+
import { LitElement, html, css } from 'lit';
24
+
25
+
export class GrainAltBadge extends LitElement {
26
+
static properties = {
27
+
alt: { type: String }
28
+
};
29
+
30
+
static styles = css`
31
+
:host {
32
+
position: absolute;
33
+
bottom: 8px;
34
+
right: 8px;
35
+
z-index: 2;
36
+
}
37
+
.badge {
38
+
background: rgba(0, 0, 0, 0.7);
39
+
color: white;
40
+
font-size: 10px;
41
+
font-weight: 600;
42
+
padding: 2px 4px;
43
+
border-radius: 4px;
44
+
cursor: pointer;
45
+
user-select: none;
46
+
border: none;
47
+
font-family: inherit;
48
+
}
49
+
.badge:hover {
50
+
background: rgba(0, 0, 0, 0.85);
51
+
}
52
+
.badge:focus {
53
+
outline: 2px solid white;
54
+
outline-offset: 1px;
55
+
}
56
+
`;
57
+
58
+
constructor() {
59
+
super();
60
+
this.alt = '';
61
+
}
62
+
63
+
#handleClick(e) {
64
+
e.stopPropagation();
65
+
this.dispatchEvent(new CustomEvent('alt-click', {
66
+
bubbles: true,
67
+
composed: true,
68
+
detail: { alt: this.alt }
69
+
}));
70
+
}
71
+
72
+
render() {
73
+
if (!this.alt) return null;
74
+
75
+
return html`
76
+
<button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button>
77
+
`;
78
+
}
79
+
}
80
+
81
+
customElements.define('grain-alt-badge', GrainAltBadge);
82
+
```
83
+
84
+
**Step 2: Verify badge still renders**
85
+
86
+
Run the app, navigate to a gallery with alt text, confirm "ALT" button appears.
87
+
88
+
---
89
+
90
+
### Task 2: Add overlay state and styles to grain-image-carousel
91
+
92
+
**Files:**
93
+
- Modify: `src/components/organisms/grain-image-carousel.js`
94
+
95
+
**Step 1: Add `_activeAltIndex` state property**
96
+
97
+
In `static properties`, add:
98
+
99
+
```js
100
+
static properties = {
101
+
photos: { type: Array },
102
+
rkey: { type: String },
103
+
_currentIndex: { state: true },
104
+
_activeAltIndex: { state: true }
105
+
};
106
+
```
107
+
108
+
**Step 2: Initialize state in constructor**
109
+
110
+
Add to constructor:
111
+
112
+
```js
113
+
this._activeAltIndex = null;
114
+
```
115
+
116
+
**Step 3: Add overlay styles**
117
+
118
+
Add to `static styles` (after `.nav-arrow-right`):
119
+
120
+
```css
121
+
.alt-overlay {
122
+
position: absolute;
123
+
inset: 0;
124
+
background: rgba(0, 0, 0, 0.75);
125
+
color: white;
126
+
padding: 16px;
127
+
font-size: 14px;
128
+
line-height: 1.5;
129
+
overflow-y: auto;
130
+
display: flex;
131
+
align-items: center;
132
+
justify-content: center;
133
+
text-align: center;
134
+
box-sizing: border-box;
135
+
z-index: 3;
136
+
cursor: pointer;
137
+
}
138
+
```
139
+
140
+
---
141
+
142
+
### Task 3: Add overlay event handlers to carousel
143
+
144
+
**Files:**
145
+
- Modify: `src/components/organisms/grain-image-carousel.js`
146
+
147
+
**Step 1: Add handler for alt-click event**
148
+
149
+
Add method:
150
+
151
+
```js
152
+
#handleAltClick(e, index) {
153
+
e.stopPropagation();
154
+
this._activeAltIndex = index;
155
+
}
156
+
```
157
+
158
+
**Step 2: Add handler for overlay click (dismiss)**
159
+
160
+
Add method:
161
+
162
+
```js
163
+
#handleOverlayClick(e) {
164
+
e.stopPropagation();
165
+
this._activeAltIndex = null;
166
+
}
167
+
```
168
+
169
+
**Step 3: Dismiss overlay on slide change**
170
+
171
+
Modify `#handleScroll` to clear overlay when swiping:
172
+
173
+
```js
174
+
#handleScroll(e) {
175
+
const carousel = e.target;
176
+
const index = Math.round(carousel.scrollLeft / carousel.offsetWidth);
177
+
if (index !== this._currentIndex) {
178
+
this._currentIndex = index;
179
+
this._activeAltIndex = null;
180
+
}
181
+
}
182
+
```
183
+
184
+
---
185
+
186
+
### Task 4: Render overlay in slide template
187
+
188
+
**Files:**
189
+
- Modify: `src/components/organisms/grain-image-carousel.js`
190
+
191
+
**Step 1: Update slide template in render()**
192
+
193
+
Replace the slide mapping (lines 153-162) with:
194
+
195
+
```js
196
+
${this.photos.map((photo, index) => html`
197
+
<div class="slide ${hasPortrait ? 'centered' : ''}">
198
+
<grain-image
199
+
src=${this.#shouldLoad(index) ? photo.url : ''}
200
+
alt=${photo.alt || ''}
201
+
aspectRatio=${photo.aspectRatio || 1}
202
+
style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''}
203
+
></grain-image>
204
+
${photo.alt ? html`
205
+
<grain-alt-badge
206
+
.alt=${photo.alt}
207
+
@alt-click=${(e) => this.#handleAltClick(e, index)}
208
+
></grain-alt-badge>
209
+
` : ''}
210
+
${this._activeAltIndex === index ? html`
211
+
<div class="alt-overlay" @click=${this.#handleOverlayClick}>
212
+
${photo.alt}
213
+
</div>
214
+
` : ''}
215
+
</div>
216
+
`)}
217
+
```
218
+
219
+
---
220
+
221
+
### Task 5: Manual testing
222
+
223
+
**Step 1: Test overlay appears correctly**
224
+
225
+
1. Navigate to a gallery with alt text
226
+
2. Click "ALT" button
227
+
3. Confirm overlay appears covering the image
228
+
229
+
**Step 2: Test overlay dismisses on click**
230
+
231
+
1. With overlay open, click the overlay
232
+
2. Confirm overlay closes
233
+
234
+
**Step 3: Test overlay dismisses on swipe**
235
+
236
+
1. Open alt overlay on first image
237
+
2. Swipe to second image
238
+
3. Confirm overlay closes
239
+
240
+
**Step 4: Test scroll behavior (the bug fix)**
241
+
242
+
1. Open alt overlay
243
+
2. Scroll the page up/down
244
+
3. Confirm overlay stays attached to the image (doesn't drift)
245
+
246
+
---
247
+
248
+
### Task 6: Commit
249
+
250
+
```bash
251
+
git add src/components/atoms/grain-alt-badge.js src/components/organisms/grain-image-carousel.js
252
+
git commit -m "fix: alt text overlay stays attached on page scroll
253
+
254
+
Move overlay rendering from grain-alt-badge to grain-image-carousel.
255
+
The overlay now uses position:absolute within the slide instead of
256
+
position:fixed with JS positioning, so it naturally scrolls with content."
257
+
```
+597
docs/plans/2026-01-04-app-level-dialog-system.md
+597
docs/plans/2026-01-04-app-level-dialog-system.md
···
1
+
# App-Level Dialog System Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Move all dialogs to app level so they render above the fixed-position outlet and display correctly on all screen sizes.
6
+
7
+
**Architecture:** Pages dispatch `open-dialog` events with type and props. `grain-app` maintains a dialog registry mapping types to render functions. Dialogs render at app root level, outside the constrained `#outlet`. Dialog-specific callbacks re-dispatch as events for pages to handle.
8
+
9
+
**Tech Stack:** Lit 3, Custom Events
10
+
11
+
---
12
+
13
+
## Task 1: Add Dialog System to grain-app
14
+
15
+
**Files:**
16
+
- Modify: `src/components/pages/grain-app.js`
17
+
18
+
**Step 1: Add imports**
19
+
20
+
Add after existing imports:
21
+
22
+
```javascript
23
+
import '../organisms/grain-action-dialog.js';
24
+
import '../organisms/grain-report-dialog.js';
25
+
import '../atoms/grain-toast.js';
26
+
```
27
+
28
+
**Step 2: Add state properties**
29
+
30
+
Add static properties to the class:
31
+
32
+
```javascript
33
+
static properties = {
34
+
_dialogType: { state: true },
35
+
_dialogProps: { state: true }
36
+
};
37
+
```
38
+
39
+
**Step 3: Add constructor**
40
+
41
+
```javascript
42
+
constructor() {
43
+
super();
44
+
this._dialogType = null;
45
+
this._dialogProps = {};
46
+
}
47
+
```
48
+
49
+
**Step 4: Add lifecycle and event handlers**
50
+
51
+
Add after constructor:
52
+
53
+
```javascript
54
+
connectedCallback() {
55
+
super.connectedCallback();
56
+
this.addEventListener('open-dialog', this.#handleOpenDialog);
57
+
}
58
+
59
+
disconnectedCallback() {
60
+
this.removeEventListener('open-dialog', this.#handleOpenDialog);
61
+
super.disconnectedCallback();
62
+
}
63
+
64
+
#handleOpenDialog = (e) => {
65
+
this._dialogType = e.detail.type;
66
+
this._dialogProps = e.detail.props || {};
67
+
};
68
+
69
+
#closeDialog = () => {
70
+
this._dialogType = null;
71
+
this._dialogProps = {};
72
+
};
73
+
74
+
#handleReportSubmitted = () => {
75
+
this.#closeDialog();
76
+
this.shadowRoot.querySelector('grain-toast')?.show('Report submitted');
77
+
};
78
+
79
+
#handleDialogAction = (e) => {
80
+
this.dispatchEvent(new CustomEvent('dialog-action', {
81
+
bubbles: true,
82
+
composed: true,
83
+
detail: e.detail
84
+
}));
85
+
};
86
+
```
87
+
88
+
**Step 5: Add dialog registry**
89
+
90
+
Add after the event handlers:
91
+
92
+
```javascript
93
+
#renderDialog() {
94
+
switch (this._dialogType) {
95
+
case 'report':
96
+
return html`
97
+
<grain-report-dialog
98
+
open
99
+
galleryUri=${this._dialogProps.galleryUri || ''}
100
+
@close=${this.#closeDialog}
101
+
@submitted=${this.#handleReportSubmitted}
102
+
></grain-report-dialog>
103
+
`;
104
+
case 'action':
105
+
return html`
106
+
<grain-action-dialog
107
+
open
108
+
.actions=${this._dialogProps.actions || []}
109
+
?loading=${this._dialogProps.loading}
110
+
loadingText=${this._dialogProps.loadingText || ''}
111
+
@close=${this.#closeDialog}
112
+
@action=${this.#handleDialogAction}
113
+
></grain-action-dialog>
114
+
`;
115
+
default:
116
+
return '';
117
+
}
118
+
}
119
+
```
120
+
121
+
**Step 6: Update render method**
122
+
123
+
Replace the render method:
124
+
125
+
```javascript
126
+
render() {
127
+
return html`
128
+
<grain-header></grain-header>
129
+
<div id="outlet"></div>
130
+
<grain-bottom-nav></grain-bottom-nav>
131
+
${this.#renderDialog()}
132
+
<grain-toast></grain-toast>
133
+
`;
134
+
}
135
+
```
136
+
137
+
**Step 7: Verify syntax**
138
+
139
+
Run: `npm run build`
140
+
Expected: Build succeeds
141
+
142
+
**Step 8: Commit**
143
+
144
+
```bash
145
+
git add src/components/pages/grain-app.js
146
+
git commit -m "feat: add app-level dialog system with registry"
147
+
```
148
+
149
+
---
150
+
151
+
## Task 2: Revert Dialog Overlay CSS
152
+
153
+
**Files:**
154
+
- Modify: `src/components/organisms/grain-action-dialog.js`
155
+
- Modify: `src/components/organisms/grain-report-dialog.js`
156
+
157
+
**Step 1: Fix grain-action-dialog overlay**
158
+
159
+
In `grain-action-dialog.js`, replace the `.overlay` CSS:
160
+
161
+
```css
162
+
.overlay {
163
+
position: fixed;
164
+
inset: 0;
165
+
background: rgba(0, 0, 0, 0.5);
166
+
display: flex;
167
+
align-items: center;
168
+
justify-content: center;
169
+
z-index: 1000;
170
+
padding: var(--space-md);
171
+
}
172
+
```
173
+
174
+
**Step 2: Fix grain-report-dialog overlay**
175
+
176
+
In `grain-report-dialog.js`, replace the `.overlay` CSS:
177
+
178
+
```css
179
+
.overlay {
180
+
position: fixed;
181
+
inset: 0;
182
+
background: rgba(0, 0, 0, 0.5);
183
+
display: flex;
184
+
align-items: center;
185
+
justify-content: center;
186
+
z-index: 1000;
187
+
padding: var(--space-md);
188
+
}
189
+
```
190
+
191
+
**Step 3: Verify syntax**
192
+
193
+
Run: `npm run build`
194
+
Expected: Build succeeds
195
+
196
+
**Step 4: Commit**
197
+
198
+
```bash
199
+
git add src/components/organisms/grain-action-dialog.js src/components/organisms/grain-report-dialog.js
200
+
git commit -m "fix: revert dialog overlay CSS to simple inset:0"
201
+
```
202
+
203
+
---
204
+
205
+
## Task 3: Update grain-timeline to Use Dialog Events
206
+
207
+
**Files:**
208
+
- Modify: `src/components/pages/grain-timeline.js`
209
+
210
+
**Step 1: Remove dialog imports**
211
+
212
+
Remove these lines:
213
+
214
+
```javascript
215
+
import '../organisms/grain-action-dialog.js';
216
+
import '../organisms/grain-report-dialog.js';
217
+
import '../atoms/grain-toast.js';
218
+
```
219
+
220
+
**Step 2: Simplify state properties**
221
+
222
+
Remove these properties:
223
+
224
+
```javascript
225
+
_menuOpen: { state: true },
226
+
_menuGallery: { state: true },
227
+
_menuIsOwner: { state: true },
228
+
_deleting: { state: true },
229
+
_reportDialogOpen: { state: true }
230
+
```
231
+
232
+
Add this one property:
233
+
234
+
```javascript
235
+
_pendingGallery: { state: true }
236
+
```
237
+
238
+
**Step 3: Simplify constructor**
239
+
240
+
Remove these initializations:
241
+
242
+
```javascript
243
+
this._menuOpen = false;
244
+
this._menuGallery = null;
245
+
this._menuIsOwner = false;
246
+
this._deleting = false;
247
+
this._reportDialogOpen = false;
248
+
```
249
+
250
+
Add:
251
+
252
+
```javascript
253
+
this._pendingGallery = null;
254
+
```
255
+
256
+
**Step 4: Add dialog-action listener in connectedCallback**
257
+
258
+
After the existing scroll listener setup:
259
+
260
+
```javascript
261
+
this.addEventListener('dialog-action', this.#handleDialogAction);
262
+
```
263
+
264
+
**Step 5: Add cleanup in disconnectedCallback**
265
+
266
+
In the existing disconnectedCallback, add:
267
+
268
+
```javascript
269
+
this.removeEventListener('dialog-action', this.#handleDialogAction);
270
+
```
271
+
272
+
**Step 6: Replace menu handlers**
273
+
274
+
Remove these methods:
275
+
- `#handleMenuClose`
276
+
- `#handleMenuAction`
277
+
- `#handleReportDialogClose`
278
+
- `#handleReportSubmitted`
279
+
280
+
Replace `#handleGalleryMenu` with:
281
+
282
+
```javascript
283
+
#handleGalleryMenu(e) {
284
+
const { gallery, isOwner } = e.detail;
285
+
this._pendingGallery = gallery;
286
+
287
+
this.dispatchEvent(new CustomEvent('open-dialog', {
288
+
bubbles: true,
289
+
composed: true,
290
+
detail: {
291
+
type: 'action',
292
+
props: {
293
+
actions: isOwner
294
+
? [{ label: 'Delete', action: 'delete', danger: true }]
295
+
: [{ label: 'Report gallery', action: 'report' }]
296
+
}
297
+
}
298
+
}));
299
+
}
300
+
```
301
+
302
+
Add new `#handleDialogAction`:
303
+
304
+
```javascript
305
+
#handleDialogAction = (e) => {
306
+
if (e.detail.action === 'delete') {
307
+
this.#handleDelete();
308
+
} else if (e.detail.action === 'report') {
309
+
this.dispatchEvent(new CustomEvent('open-dialog', {
310
+
bubbles: true,
311
+
composed: true,
312
+
detail: {
313
+
type: 'report',
314
+
props: { galleryUri: this._pendingGallery?.uri }
315
+
}
316
+
}));
317
+
}
318
+
};
319
+
```
320
+
321
+
**Step 7: Update #handleDelete**
322
+
323
+
Replace the method with:
324
+
325
+
```javascript
326
+
async #handleDelete() {
327
+
if (!this._pendingGallery) return;
328
+
329
+
// Show loading state
330
+
this.dispatchEvent(new CustomEvent('open-dialog', {
331
+
bubbles: true,
332
+
composed: true,
333
+
detail: {
334
+
type: 'action',
335
+
props: {
336
+
actions: [{ label: 'Delete', action: 'delete', danger: true }],
337
+
loading: true,
338
+
loadingText: 'Deleting...'
339
+
}
340
+
}
341
+
}));
342
+
343
+
try {
344
+
const client = auth.getClient();
345
+
const rkey = this._pendingGallery.uri.split('/').pop();
346
+
347
+
await client.mutate(`
348
+
mutation DeleteGallery($rkey: String!) {
349
+
deleteSocialGrainGallery(rkey: $rkey) { uri }
350
+
}
351
+
`, { rkey });
352
+
353
+
this._galleries = this._galleries.filter(g => g.uri !== this._pendingGallery.uri);
354
+
this._pendingGallery = null;
355
+
356
+
// Close dialog by dispatching close (app listens)
357
+
this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true }));
358
+
} catch (err) {
359
+
console.error('Failed to delete gallery:', err);
360
+
this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true }));
361
+
}
362
+
}
363
+
```
364
+
365
+
**Step 8: Remove dialogs from render**
366
+
367
+
Remove these elements from the render method:
368
+
369
+
```javascript
370
+
<grain-action-dialog ...></grain-action-dialog>
371
+
<grain-report-dialog ...></grain-report-dialog>
372
+
<grain-toast></grain-toast>
373
+
```
374
+
375
+
**Step 9: Verify syntax**
376
+
377
+
Run: `npm run build`
378
+
Expected: Build succeeds
379
+
380
+
**Step 10: Commit**
381
+
382
+
```bash
383
+
git add src/components/pages/grain-timeline.js
384
+
git commit -m "refactor: use app-level dialog events in timeline"
385
+
```
386
+
387
+
---
388
+
389
+
## Task 4: Update grain-gallery-detail to Use Dialog Events
390
+
391
+
**Files:**
392
+
- Modify: `src/components/pages/grain-gallery-detail.js`
393
+
394
+
**Step 1: Remove dialog imports**
395
+
396
+
Remove these lines:
397
+
398
+
```javascript
399
+
import '../organisms/grain-report-dialog.js';
400
+
import '../atoms/grain-toast.js';
401
+
```
402
+
403
+
**Step 2: Remove state property**
404
+
405
+
Remove:
406
+
407
+
```javascript
408
+
_reportDialogOpen: { state: true }
409
+
```
410
+
411
+
**Step 3: Remove constructor initialization**
412
+
413
+
Remove:
414
+
415
+
```javascript
416
+
this._reportDialogOpen = false;
417
+
```
418
+
419
+
**Step 4: Add dialog-action listener**
420
+
421
+
In connectedCallback (add the method if it doesn't exist):
422
+
423
+
```javascript
424
+
connectedCallback() {
425
+
super.connectedCallback();
426
+
this.#loadGallery();
427
+
this.addEventListener('dialog-action', this.#handleDialogAction);
428
+
}
429
+
```
430
+
431
+
Add disconnectedCallback cleanup:
432
+
433
+
```javascript
434
+
// In existing disconnectedCallback, add:
435
+
this.removeEventListener('dialog-action', this.#handleDialogAction);
436
+
```
437
+
438
+
**Step 5: Replace report dialog handlers**
439
+
440
+
Remove:
441
+
- `#handleReportDialogClose`
442
+
- `#handleReportSubmitted`
443
+
444
+
Update `#handleAction`:
445
+
446
+
```javascript
447
+
async #handleAction(e) {
448
+
if (e.detail.action === 'delete') {
449
+
await this.#handleDelete();
450
+
} else if (e.detail.action === 'report') {
451
+
this.dispatchEvent(new CustomEvent('open-dialog', {
452
+
bubbles: true,
453
+
composed: true,
454
+
detail: {
455
+
type: 'report',
456
+
props: { galleryUri: this._gallery?.uri }
457
+
}
458
+
}));
459
+
}
460
+
}
461
+
```
462
+
463
+
Add `#handleDialogAction`:
464
+
465
+
```javascript
466
+
#handleDialogAction = (e) => {
467
+
// Handle actions dispatched back from app-level dialog
468
+
if (e.detail.action === 'delete') {
469
+
this.#handleDelete();
470
+
} else if (e.detail.action === 'report') {
471
+
this.dispatchEvent(new CustomEvent('open-dialog', {
472
+
bubbles: true,
473
+
composed: true,
474
+
detail: {
475
+
type: 'report',
476
+
props: { galleryUri: this._gallery?.uri }
477
+
}
478
+
}));
479
+
}
480
+
};
481
+
```
482
+
483
+
**Step 6: Update menu to use app-level action dialog**
484
+
485
+
Replace the inline action dialog approach. Update `#handleMenuOpen`:
486
+
487
+
```javascript
488
+
#handleMenuOpen() {
489
+
this.dispatchEvent(new CustomEvent('open-dialog', {
490
+
bubbles: true,
491
+
composed: true,
492
+
detail: {
493
+
type: 'action',
494
+
props: {
495
+
actions: this.#isOwner
496
+
? [{ label: 'Delete', action: 'delete', danger: true }]
497
+
: [{ label: 'Report gallery', action: 'report' }]
498
+
}
499
+
}
500
+
}));
501
+
}
502
+
```
503
+
504
+
Remove `#handleMenuClose` method.
505
+
506
+
Remove `_menuOpen` state property and constructor initialization.
507
+
508
+
**Step 7: Remove dialogs from render**
509
+
510
+
Remove these elements from render:
511
+
512
+
```javascript
513
+
<grain-action-dialog ...></grain-action-dialog>
514
+
<grain-report-dialog ...></grain-report-dialog>
515
+
<grain-toast></grain-toast>
516
+
```
517
+
518
+
**Step 8: Verify syntax**
519
+
520
+
Run: `npm run build`
521
+
Expected: Build succeeds
522
+
523
+
**Step 9: Commit**
524
+
525
+
```bash
526
+
git add src/components/pages/grain-gallery-detail.js
527
+
git commit -m "refactor: use app-level dialog events in gallery detail"
528
+
```
529
+
530
+
---
531
+
532
+
## Task 5: Add close-dialog Event Support to grain-app
533
+
534
+
**Files:**
535
+
- Modify: `src/components/pages/grain-app.js`
536
+
537
+
**Step 1: Add close-dialog listener**
538
+
539
+
In connectedCallback, add:
540
+
541
+
```javascript
542
+
this.addEventListener('close-dialog', this.#closeDialog);
543
+
```
544
+
545
+
In disconnectedCallback, add:
546
+
547
+
```javascript
548
+
this.removeEventListener('close-dialog', this.#closeDialog);
549
+
```
550
+
551
+
**Step 2: Verify syntax**
552
+
553
+
Run: `npm run build`
554
+
Expected: Build succeeds
555
+
556
+
**Step 3: Commit**
557
+
558
+
```bash
559
+
git add src/components/pages/grain-app.js
560
+
git commit -m "feat: add close-dialog event support"
561
+
```
562
+
563
+
---
564
+
565
+
## Task 6: Final Verification
566
+
567
+
**Step 1: Full build**
568
+
569
+
Run: `npm run build`
570
+
Expected: Build succeeds with no errors
571
+
572
+
**Step 2: Manual testing**
573
+
574
+
Run: `npm run dev`
575
+
576
+
Test these scenarios:
577
+
1. Timeline: Click ellipsis on other's gallery โ "Report gallery" action โ Report dialog opens fullscreen
578
+
2. Timeline: Click ellipsis on own gallery โ "Delete" action
579
+
3. Gallery detail: Same tests as above
580
+
4. Report submission shows toast
581
+
5. Dialogs close when clicking overlay
582
+
6. Dialogs display correctly on small screens (no cut-off)
583
+
584
+
**Step 3: Commit any fixes if needed**
585
+
586
+
---
587
+
588
+
## Summary
589
+
590
+
| Task | Files | Description |
591
+
|------|-------|-------------|
592
+
| 1 | grain-app.js | Add dialog registry system |
593
+
| 2 | grain-action-dialog.js, grain-report-dialog.js | Revert overlay CSS |
594
+
| 3 | grain-timeline.js | Use dialog events |
595
+
| 4 | grain-gallery-detail.js | Use dialog events |
596
+
| 5 | grain-app.js | Add close-dialog support |
597
+
| 6 | - | Final verification |
+172
docs/plans/2026-01-04-haptic-feedback.md
+172
docs/plans/2026-01-04-haptic-feedback.md
···
1
+
# Haptic Feedback Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Add haptic feedback to bottom navigation taps for iOS 18+ and Android PWA users.
6
+
7
+
**Architecture:** A small utility module (`haptics.js`) that uses the iOS 18 checkbox-switch hack for Safari and falls back to `navigator.vibrate()` for Android. The bottom nav component imports and calls the trigger function in each navigation handler.
8
+
9
+
**Tech Stack:** Vanilla JS, Lit elements, Web Vibration API, iOS 18 checkbox switch attribute
10
+
11
+
---
12
+
13
+
### Task 1: Create Haptics Utility
14
+
15
+
**Files:**
16
+
- Create: `src/utils/haptics.js`
17
+
18
+
**Step 1: Create the haptics utility file**
19
+
20
+
```js
21
+
/**
22
+
* Haptic feedback utility for PWA
23
+
* - iOS 18+: Uses checkbox switch element hack
24
+
* - Android: Uses Vibration API
25
+
* - Other: Silently does nothing
26
+
*/
27
+
28
+
// Platform detection
29
+
const isIOS = /iPhone|iPad/.test(navigator.userAgent);
30
+
const hasVibrate = 'vibrate' in navigator;
31
+
32
+
// Lazy-initialized hidden elements for iOS
33
+
let checkbox = null;
34
+
let label = null;
35
+
36
+
function ensureElements() {
37
+
if (checkbox) return;
38
+
39
+
checkbox = document.createElement('input');
40
+
checkbox.type = 'checkbox';
41
+
checkbox.setAttribute('switch', '');
42
+
checkbox.id = 'haptic-trigger';
43
+
checkbox.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;';
44
+
45
+
label = document.createElement('label');
46
+
label.htmlFor = 'haptic-trigger';
47
+
label.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;';
48
+
49
+
document.body.append(checkbox, label);
50
+
}
51
+
52
+
/**
53
+
* Trigger a light haptic tap
54
+
*/
55
+
export function trigger() {
56
+
if (isIOS) {
57
+
ensureElements();
58
+
label.click();
59
+
} else if (hasVibrate) {
60
+
navigator.vibrate(10);
61
+
}
62
+
}
63
+
64
+
/**
65
+
* Check if haptics are supported on this device
66
+
*/
67
+
export function isSupported() {
68
+
return isIOS || hasVibrate;
69
+
}
70
+
```
71
+
72
+
**Step 2: Commit the utility**
73
+
74
+
```bash
75
+
git add src/utils/haptics.js
76
+
git commit -m "feat: add haptics utility for iOS 18+ and Android"
77
+
```
78
+
79
+
---
80
+
81
+
### Task 2: Integrate Haptics into Bottom Nav
82
+
83
+
**Files:**
84
+
- Modify: `src/components/organisms/grain-bottom-nav.js`
85
+
86
+
**Step 1: Add the import**
87
+
88
+
At the top of the file with other imports, add:
89
+
90
+
```js
91
+
import { trigger as haptic } from '../../utils/haptics.js';
92
+
```
93
+
94
+
**Step 2: Add haptic calls to navigation handlers**
95
+
96
+
Find each handler method and add `haptic();` as the first line:
97
+
98
+
```js
99
+
#handleHome() {
100
+
haptic();
101
+
router.push('/');
102
+
}
103
+
104
+
#handleProfile() {
105
+
haptic();
106
+
router.push(`/profile/${this._user.handle}`);
107
+
}
108
+
109
+
#handleExplore() {
110
+
haptic();
111
+
router.push('/explore');
112
+
}
113
+
114
+
#handleNotifications() {
115
+
haptic();
116
+
router.push('/notifications');
117
+
}
118
+
119
+
#handleCreate() {
120
+
haptic();
121
+
this._fileInput.click();
122
+
}
123
+
```
124
+
125
+
**Step 3: Commit the integration**
126
+
127
+
```bash
128
+
git add src/components/organisms/grain-bottom-nav.js
129
+
git commit -m "feat: add haptic feedback to bottom nav taps"
130
+
```
131
+
132
+
---
133
+
134
+
### Task 3: Manual Testing
135
+
136
+
**Step 1: Start the dev server**
137
+
138
+
```bash
139
+
npm run dev
140
+
```
141
+
142
+
**Step 2: Test on iOS 18+ device**
143
+
144
+
- Open the app in Safari on iPhone/iPad running iOS 18+
145
+
- Add to Home Screen (PWA mode)
146
+
- Tap each bottom nav item
147
+
- Expected: Light haptic tap on each navigation
148
+
149
+
**Step 3: Test on Android device**
150
+
151
+
- Open the app in Chrome on Android
152
+
- Add to Home Screen or test in browser
153
+
- Tap each bottom nav item
154
+
- Expected: Light vibration (10ms) on each navigation
155
+
156
+
**Step 4: Test on desktop**
157
+
158
+
- Open the app in any desktop browser
159
+
- Click each bottom nav item
160
+
- Expected: No errors, navigation works normally, no haptic (graceful degradation)
161
+
162
+
---
163
+
164
+
## Summary
165
+
166
+
| Task | Description | Files |
167
+
|------|-------------|-------|
168
+
| 1 | Create haptics utility | `src/utils/haptics.js` |
169
+
| 2 | Integrate into bottom nav | `src/components/organisms/grain-bottom-nav.js` |
170
+
| 3 | Manual testing | N/A |
171
+
172
+
**Total changes:** 2 files, ~40 lines of code
+74
lexicons/app/bsky/actor/profile.json
+74
lexicons/app/bsky/actor/profile.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "app.bsky.actor.profile",
4
+
"defs": {
5
+
"main": {
6
+
"key": "literal:self",
7
+
"type": "record",
8
+
"record": {
9
+
"type": "object",
10
+
"properties": {
11
+
"avatar": {
12
+
"type": "blob",
13
+
"accept": [
14
+
"image/png",
15
+
"image/jpeg"
16
+
],
17
+
"maxSize": 1000000,
18
+
"description": "Small image to be displayed next to posts from account. AKA, 'profile picture'"
19
+
},
20
+
"banner": {
21
+
"type": "blob",
22
+
"accept": [
23
+
"image/png",
24
+
"image/jpeg"
25
+
],
26
+
"maxSize": 1000000,
27
+
"description": "Larger horizontal image to display behind profile view."
28
+
},
29
+
"labels": {
30
+
"refs": [
31
+
"com.atproto.label.defs#selfLabels"
32
+
],
33
+
"type": "union",
34
+
"description": "Self-label values, specific to the Bluesky application, on the overall account."
35
+
},
36
+
"website": {
37
+
"type": "string",
38
+
"format": "uri"
39
+
},
40
+
"pronouns": {
41
+
"type": "string",
42
+
"maxLength": 200,
43
+
"description": "Free-form pronouns text.",
44
+
"maxGraphemes": 20
45
+
},
46
+
"createdAt": {
47
+
"type": "string",
48
+
"format": "datetime"
49
+
},
50
+
"pinnedPost": {
51
+
"ref": "com.atproto.repo.strongRef",
52
+
"type": "ref"
53
+
},
54
+
"description": {
55
+
"type": "string",
56
+
"maxLength": 2560,
57
+
"description": "Free-form profile description text.",
58
+
"maxGraphemes": 256
59
+
},
60
+
"displayName": {
61
+
"type": "string",
62
+
"maxLength": 640,
63
+
"maxGraphemes": 64
64
+
},
65
+
"joinedViaStarterPack": {
66
+
"ref": "com.atproto.repo.strongRef",
67
+
"type": "ref"
68
+
}
69
+
}
70
+
},
71
+
"description": "A declaration of a Bluesky account profile."
72
+
}
73
+
}
74
+
}
+73
lexicons/app/bsky/richtext/factet.json
+73
lexicons/app/bsky/richtext/factet.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "app.bsky.richtext.facet",
4
+
"defs": {
5
+
"tag": {
6
+
"type": "object",
7
+
"required": ["tag"],
8
+
"properties": {
9
+
"tag": {
10
+
"type": "string",
11
+
"maxLength": 640,
12
+
"maxGraphemes": 64
13
+
}
14
+
},
15
+
"description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags')."
16
+
},
17
+
"link": {
18
+
"type": "object",
19
+
"required": ["uri"],
20
+
"properties": {
21
+
"uri": {
22
+
"type": "string",
23
+
"format": "uri"
24
+
}
25
+
},
26
+
"description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL."
27
+
},
28
+
"main": {
29
+
"type": "object",
30
+
"required": ["index", "features"],
31
+
"properties": {
32
+
"index": {
33
+
"ref": "#byteSlice",
34
+
"type": "ref"
35
+
},
36
+
"features": {
37
+
"type": "array",
38
+
"items": {
39
+
"refs": ["#mention", "#link", "#tag"],
40
+
"type": "union"
41
+
}
42
+
}
43
+
},
44
+
"description": "Annotation of a sub-string within rich text."
45
+
},
46
+
"mention": {
47
+
"type": "object",
48
+
"required": ["did"],
49
+
"properties": {
50
+
"did": {
51
+
"type": "string",
52
+
"format": "did"
53
+
}
54
+
},
55
+
"description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID."
56
+
},
57
+
"byteSlice": {
58
+
"type": "object",
59
+
"required": ["byteStart", "byteEnd"],
60
+
"properties": {
61
+
"byteEnd": {
62
+
"type": "integer",
63
+
"minimum": 0
64
+
},
65
+
"byteStart": {
66
+
"type": "integer",
67
+
"minimum": 0
68
+
}
69
+
},
70
+
"description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets."
71
+
}
72
+
}
73
+
}
+192
lexicons/com/atproto/label/defs.json
+192
lexicons/com/atproto/label/defs.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "com.atproto.label.defs",
4
+
"defs": {
5
+
"label": {
6
+
"type": "object",
7
+
"required": [
8
+
"src",
9
+
"uri",
10
+
"val",
11
+
"cts"
12
+
],
13
+
"properties": {
14
+
"cid": {
15
+
"type": "string",
16
+
"format": "cid",
17
+
"description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."
18
+
},
19
+
"cts": {
20
+
"type": "string",
21
+
"format": "datetime",
22
+
"description": "Timestamp when this label was created."
23
+
},
24
+
"exp": {
25
+
"type": "string",
26
+
"format": "datetime",
27
+
"description": "Timestamp at which this label expires (no longer applies)."
28
+
},
29
+
"neg": {
30
+
"type": "boolean",
31
+
"description": "If true, this is a negation label, overwriting a previous label."
32
+
},
33
+
"sig": {
34
+
"type": "bytes",
35
+
"description": "Signature of dag-cbor encoded label."
36
+
},
37
+
"src": {
38
+
"type": "string",
39
+
"format": "did",
40
+
"description": "DID of the actor who created this label."
41
+
},
42
+
"uri": {
43
+
"type": "string",
44
+
"format": "uri",
45
+
"description": "AT URI of the record, repository (account), or other resource that this label applies to."
46
+
},
47
+
"val": {
48
+
"type": "string",
49
+
"maxLength": 128,
50
+
"description": "The short string name of the value or type of this label."
51
+
},
52
+
"ver": {
53
+
"type": "integer",
54
+
"description": "The AT Protocol version of the label object."
55
+
}
56
+
},
57
+
"description": "Metadata tag on an atproto resource (eg, repo or record)."
58
+
},
59
+
"selfLabel": {
60
+
"type": "object",
61
+
"required": [
62
+
"val"
63
+
],
64
+
"properties": {
65
+
"val": {
66
+
"type": "string",
67
+
"maxLength": 128,
68
+
"description": "The short string name of the value or type of this label."
69
+
}
70
+
},
71
+
"description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel."
72
+
},
73
+
"labelValue": {
74
+
"type": "string",
75
+
"knownValues": [
76
+
"!hide",
77
+
"!no-promote",
78
+
"!warn",
79
+
"!no-unauthenticated",
80
+
"dmca-violation",
81
+
"doxxing",
82
+
"porn",
83
+
"sexual",
84
+
"nudity",
85
+
"nsfl",
86
+
"gore"
87
+
]
88
+
},
89
+
"selfLabels": {
90
+
"type": "object",
91
+
"required": [
92
+
"values"
93
+
],
94
+
"properties": {
95
+
"values": {
96
+
"type": "array",
97
+
"items": {
98
+
"ref": "#selfLabel",
99
+
"type": "ref"
100
+
},
101
+
"maxLength": 10
102
+
}
103
+
},
104
+
"description": "Metadata tags on an atproto record, published by the author within the record."
105
+
},
106
+
"labelValueDefinition": {
107
+
"type": "object",
108
+
"required": [
109
+
"identifier",
110
+
"severity",
111
+
"blurs",
112
+
"locales"
113
+
],
114
+
"properties": {
115
+
"blurs": {
116
+
"type": "string",
117
+
"description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
118
+
"knownValues": [
119
+
"content",
120
+
"media",
121
+
"none"
122
+
]
123
+
},
124
+
"locales": {
125
+
"type": "array",
126
+
"items": {
127
+
"ref": "#labelValueDefinitionStrings",
128
+
"type": "ref"
129
+
}
130
+
},
131
+
"severity": {
132
+
"type": "string",
133
+
"description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
134
+
"knownValues": [
135
+
"inform",
136
+
"alert",
137
+
"none"
138
+
]
139
+
},
140
+
"adultOnly": {
141
+
"type": "boolean",
142
+
"description": "Does the user need to have adult content enabled in order to configure this label?"
143
+
},
144
+
"identifier": {
145
+
"type": "string",
146
+
"maxLength": 100,
147
+
"description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
148
+
"maxGraphemes": 100
149
+
},
150
+
"defaultSetting": {
151
+
"type": "string",
152
+
"default": "warn",
153
+
"description": "The default setting for this label.",
154
+
"knownValues": [
155
+
"ignore",
156
+
"warn",
157
+
"hide"
158
+
]
159
+
}
160
+
},
161
+
"description": "Declares a label value and its expected interpretations and behaviors."
162
+
},
163
+
"labelValueDefinitionStrings": {
164
+
"type": "object",
165
+
"required": [
166
+
"lang",
167
+
"name",
168
+
"description"
169
+
],
170
+
"properties": {
171
+
"lang": {
172
+
"type": "string",
173
+
"format": "language",
174
+
"description": "The code of the language these strings are written in."
175
+
},
176
+
"name": {
177
+
"type": "string",
178
+
"maxLength": 640,
179
+
"description": "A short human-readable name for the label.",
180
+
"maxGraphemes": 64
181
+
},
182
+
"description": {
183
+
"type": "string",
184
+
"maxLength": 100000,
185
+
"description": "A longer description of what the label means and why it might be applied.",
186
+
"maxGraphemes": 10000
187
+
}
188
+
},
189
+
"description": "Strings which describe the label in the UI, localized into a specific language."
190
+
}
191
+
}
192
+
}
+24
lexicons/com/atproto/repo/strongRef.json
+24
lexicons/com/atproto/repo/strongRef.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "com.atproto.repo.strongRef",
4
+
"description": "A URI with a content-hash fingerprint.",
5
+
"defs": {
6
+
"main": {
7
+
"type": "object",
8
+
"required": [
9
+
"uri",
10
+
"cid"
11
+
],
12
+
"properties": {
13
+
"cid": {
14
+
"type": "string",
15
+
"format": "cid"
16
+
},
17
+
"uri": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
}
21
+
}
22
+
}
23
+
}
24
+
}
+3
-3
package-lock.json
+3
-3
package-lock.json
···
1
1
{
2
-
"name": "grain-next",
2
+
"name": "grain",
3
3
"version": "1.0.0",
4
4
"lockfileVersion": 3,
5
5
"requires": true,
6
6
"packages": {
7
7
"": {
8
-
"name": "grain-next",
8
+
"name": "grain",
9
9
"version": "1.0.0",
10
-
"license": "ISC",
10
+
"license": "Apache-2.0",
11
11
"dependencies": {
12
12
"@fortawesome/fontawesome-free": "^7.1.0",
13
13
"lit": "^3.3.2",
+59
src/components/atoms/grain-alt-badge.js
+59
src/components/atoms/grain-alt-badge.js
···
1
+
import { LitElement, html, css } from 'lit';
2
+
3
+
export class GrainAltBadge extends LitElement {
4
+
static properties = {
5
+
alt: { type: String }
6
+
};
7
+
8
+
static styles = css`
9
+
:host {
10
+
position: absolute;
11
+
bottom: 8px;
12
+
right: 8px;
13
+
z-index: 2;
14
+
}
15
+
.badge {
16
+
background: rgba(0, 0, 0, 0.7);
17
+
color: white;
18
+
font-size: 10px;
19
+
font-weight: 600;
20
+
padding: 2px 4px;
21
+
border-radius: 4px;
22
+
cursor: pointer;
23
+
user-select: none;
24
+
border: none;
25
+
font-family: inherit;
26
+
}
27
+
.badge:hover {
28
+
background: rgba(0, 0, 0, 0.85);
29
+
}
30
+
.badge:focus {
31
+
outline: 2px solid white;
32
+
outline-offset: 1px;
33
+
}
34
+
`;
35
+
36
+
constructor() {
37
+
super();
38
+
this.alt = '';
39
+
}
40
+
41
+
#handleClick(e) {
42
+
e.stopPropagation();
43
+
this.dispatchEvent(new CustomEvent('alt-click', {
44
+
bubbles: true,
45
+
composed: true,
46
+
detail: { alt: this.alt }
47
+
}));
48
+
}
49
+
50
+
render() {
51
+
if (!this.alt) return null;
52
+
53
+
return html`
54
+
<button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button>
55
+
`;
56
+
}
57
+
}
58
+
59
+
customElements.define('grain-alt-badge', GrainAltBadge);
+4
-1
src/components/atoms/grain-icon.js
+4
-1
src/components/atoms/grain-icon.js
···
6
6
heartFilled: 'fa-solid fa-heart',
7
7
comment: 'fa-regular fa-comment',
8
8
back: 'fa-solid fa-arrow-left',
9
+
arrowUp: 'fa-solid fa-arrow-up',
9
10
home: 'fa-solid fa-house',
10
11
homeLine: 'fa-regular fa-house',
11
12
user: 'fa-regular fa-user',
···
22
23
share: 'fa-solid fa-arrow-up-from-bracket',
23
24
camera: 'fa-solid fa-camera',
24
25
paperPlane: 'fa-regular fa-paper-plane',
25
-
close: 'fa-solid fa-xmark'
26
+
close: 'fa-solid fa-xmark',
27
+
chevronLeft: 'fa-solid fa-chevron-left',
28
+
chevronRight: 'fa-solid fa-chevron-right'
26
29
};
27
30
28
31
export class GrainIcon extends LitElement {
+81
src/components/atoms/grain-scroll-to-top.js
+81
src/components/atoms/grain-scroll-to-top.js
···
1
+
import { LitElement, html, css } from 'lit';
2
+
import './grain-icon.js';
3
+
4
+
export class GrainScrollToTop extends LitElement {
5
+
static properties = {
6
+
visible: { type: Boolean }
7
+
};
8
+
9
+
static styles = css`
10
+
:host {
11
+
position: sticky;
12
+
bottom: var(--space-sm);
13
+
left: 0;
14
+
align-self: flex-start;
15
+
z-index: 100;
16
+
margin-top: auto;
17
+
margin-left: var(--space-sm);
18
+
}
19
+
@media (min-width: 768px) {
20
+
:host {
21
+
position: fixed;
22
+
bottom: calc(57px + env(safe-area-inset-bottom, 0px) + var(--space-lg));
23
+
left: calc(50% - var(--feed-max-width) / 2 - 64px);
24
+
margin-left: 0;
25
+
margin-top: 0;
26
+
}
27
+
}
28
+
button {
29
+
display: flex;
30
+
align-items: center;
31
+
justify-content: center;
32
+
width: 42px;
33
+
height: 42px;
34
+
border-radius: 50%;
35
+
border: 1px solid var(--color-border);
36
+
background: var(--color-bg-primary);
37
+
color: white;
38
+
cursor: pointer;
39
+
opacity: 0;
40
+
pointer-events: none;
41
+
transition: opacity 0.2s ease-in-out;
42
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
43
+
}
44
+
button.visible {
45
+
opacity: 1;
46
+
pointer-events: auto;
47
+
}
48
+
button:hover {
49
+
filter: brightness(1.1);
50
+
}
51
+
button:active {
52
+
transform: scale(0.95);
53
+
}
54
+
`;
55
+
56
+
constructor() {
57
+
super();
58
+
this.visible = false;
59
+
}
60
+
61
+
#handleClick() {
62
+
this.dispatchEvent(new CustomEvent('scroll-top', {
63
+
bubbles: true,
64
+
composed: true
65
+
}));
66
+
}
67
+
68
+
render() {
69
+
return html`
70
+
<button
71
+
class=${this.visible ? 'visible' : ''}
72
+
@click=${this.#handleClick}
73
+
aria-label="Scroll to top"
74
+
>
75
+
<grain-icon name="arrowUp" size="20"></grain-icon>
76
+
</button>
77
+
`;
78
+
}
79
+
}
80
+
81
+
customElements.define('grain-scroll-to-top', GrainScrollToTop);
+2
src/components/atoms/grain-textarea.js
+2
src/components/atoms/grain-textarea.js
+38
-6
src/components/molecules/grain-login-dialog.js
+38
-6
src/components/molecules/grain-login-dialog.js
···
1
1
import { LitElement, html, css } from 'lit';
2
+
import '../atoms/grain-close-button.js';
2
3
3
4
export class GrainLoginDialog extends LitElement {
4
5
static properties = {
···
24
25
z-index: 1000;
25
26
}
26
27
.dialog {
27
-
background: var(--color-bg-primary);
28
-
border-radius: 12px;
28
+
position: relative;
29
+
background: var(--color-bg-elevated);
30
+
border-radius: 20px;
29
31
padding: var(--space-lg);
30
32
width: 90%;
31
33
max-width: 320px;
34
+
}
35
+
grain-close-button {
36
+
position: absolute;
37
+
top: var(--space-sm);
38
+
right: var(--space-sm);
32
39
}
33
40
h2 {
34
41
margin: 0 0 var(--space-md);
···
40
47
display: block;
41
48
width: 100%;
42
49
margin-bottom: var(--space-md);
43
-
--qs-input-bg: #000000;
50
+
--qs-input-bg: #1a1a1a;
44
51
--qs-input-border: #363636;
45
52
--qs-input-border-focus: #fafafa;
46
53
--qs-input-text: #fafafa;
···
53
60
--qs-handle-color: #fafafa;
54
61
--qs-name-color: #a8a8a8;
55
62
}
56
-
button {
63
+
button[type="submit"] {
57
64
width: 100%;
58
65
padding: var(--space-sm);
59
66
background: var(--color-text-primary);
···
64
71
font-weight: var(--font-weight-semibold);
65
72
cursor: pointer;
66
73
}
67
-
button:disabled {
74
+
button[type="submit"]:disabled {
68
75
opacity: 0.5;
69
76
cursor: not-allowed;
70
77
}
78
+
.legal-links {
79
+
margin-top: var(--space-md);
80
+
text-align: center;
81
+
font-size: var(--font-size-xs);
82
+
color: var(--color-text-secondary);
83
+
}
84
+
.legal-links a {
85
+
color: var(--color-text-secondary);
86
+
text-decoration: underline;
87
+
}
88
+
.legal-links a:hover {
89
+
color: var(--color-text-primary);
90
+
}
71
91
`;
72
92
73
93
constructor() {
···
81
101
this._open = true;
82
102
this._handle = '';
83
103
this._loading = false;
104
+
document.addEventListener('keydown', this.#handleKeyDown);
84
105
}
85
106
86
107
close() {
87
108
this._open = false;
88
109
this._handle = '';
89
110
this._loading = false;
111
+
document.removeEventListener('keydown', this.#handleKeyDown);
90
112
}
91
113
114
+
#handleKeyDown = (e) => {
115
+
if (e.key === 'Escape') {
116
+
this.close();
117
+
}
118
+
};
119
+
92
120
#handleSubmit(e) {
93
121
e.preventDefault();
94
122
if (!this._handle.trim()) return;
···
111
139
return html`
112
140
<div class="overlay" @click=${this.#handleOverlayClick}>
113
141
<form class="dialog" @submit=${this.#handleSubmit}>
114
-
<h2>Login with Bluesky</h2>
142
+
<grain-close-button @close=${() => this.close()}></grain-close-button>
143
+
<h2>Login with AT Protocol</h2>
115
144
<qs-actor-autocomplete
116
145
name="handle"
117
146
placeholder="handle.bsky.social"
···
123
152
<button type="submit" ?disabled=${this._loading || !this._handle.trim()}>
124
153
${this._loading ? 'Redirecting...' : 'Continue'}
125
154
</button>
155
+
<p class="legal-links">
156
+
By continuing, you agree to our <a href="/legal/terms">Terms</a> and <a href="/legal/privacy">Privacy Policy</a>
157
+
</p>
126
158
</form>
127
159
</div>
128
160
`;
+30
-1
src/components/organisms/grain-action-dialog.js
+30
-1
src/components/organisms/grain-action-dialog.js
···
28
28
}
29
29
.dialog {
30
30
background: var(--color-bg-primary);
31
+
border: 1px solid var(--color-border);
31
32
border-radius: 12px;
32
33
min-width: 280px;
33
34
max-width: 320px;
···
53
54
background: var(--color-bg-secondary);
54
55
}
55
56
.action-button.danger {
56
-
color: #ff4444;
57
+
color: var(--color-error);
57
58
}
58
59
.action-button.cancel {
59
60
color: var(--color-text-secondary);
···
69
70
}
70
71
`;
71
72
73
+
#boundHandleKeydown = null;
74
+
72
75
constructor() {
73
76
super();
74
77
this.open = false;
75
78
this.actions = [];
76
79
this.loading = false;
77
80
this.loadingText = 'Loading...';
81
+
this.#boundHandleKeydown = this.#handleKeydown.bind(this);
82
+
}
83
+
84
+
connectedCallback() {
85
+
super.connectedCallback();
86
+
document.addEventListener('keydown', this.#boundHandleKeydown);
87
+
}
88
+
89
+
disconnectedCallback() {
90
+
document.removeEventListener('keydown', this.#boundHandleKeydown);
91
+
super.disconnectedCallback();
92
+
}
93
+
94
+
#handleKeydown(e) {
95
+
if (e.key === 'Escape' && this.open && !this.loading) {
96
+
this.#close();
97
+
}
98
+
}
99
+
100
+
updated(changedProperties) {
101
+
if (changedProperties.has('open') && this.open) {
102
+
// Focus first action button when dialog opens
103
+
requestAnimationFrame(() => {
104
+
this.shadowRoot.querySelector('.action-button')?.focus();
105
+
});
106
+
}
78
107
}
79
108
80
109
#handleOverlayClick(e) {
+14
-15
src/components/organisms/grain-comment-sheet.js
+14
-15
src/components/organisms/grain-comment-sheet.js
···
6
6
import '../molecules/grain-comment.js';
7
7
import '../molecules/grain-comment-input.js';
8
8
import '../atoms/grain-spinner.js';
9
-
import '../atoms/grain-icon.js';
9
+
import '../atoms/grain-close-button.js';
10
10
11
11
export class GrainCommentSheet extends LitElement {
12
12
static properties = {
···
78
78
font-size: var(--font-size-md);
79
79
font-weight: var(--font-weight-semibold);
80
80
}
81
-
.close-button {
81
+
grain-close-button {
82
82
position: absolute;
83
83
right: var(--space-sm);
84
-
background: none;
85
-
border: none;
86
-
padding: var(--space-sm);
87
-
cursor: pointer;
88
-
color: var(--color-text-primary);
89
84
}
90
85
.comments-list {
91
86
flex: 1;
···
212
207
}
213
208
214
209
#handleClose() {
215
-
this.open = false;
216
-
this._replyToUri = null;
217
-
this._replyToHandle = null;
218
-
this._inputValue = '';
219
-
this.dispatchEvent(new CustomEvent('close'));
210
+
// Blur active element first to release iOS focus/scroll context
211
+
document.activeElement?.blur();
212
+
213
+
// Small delay to let iOS finish processing touch before hiding
214
+
requestAnimationFrame(() => {
215
+
this.open = false;
216
+
this._replyToUri = null;
217
+
this._replyToHandle = null;
218
+
this._inputValue = '';
219
+
this.dispatchEvent(new CustomEvent('close'));
220
+
});
220
221
}
221
222
222
223
#handleOverlayClick() {
···
343
344
<div class="sheet">
344
345
<div class="header">
345
346
<h2>Comments</h2>
346
-
<button class="close-button" @click=${this.#handleClose}>
347
-
<grain-icon name="close" size="20"></grain-icon>
348
-
</button>
347
+
<grain-close-button @close=${this.#handleClose}></grain-close-button>
349
348
</div>
350
349
351
350
<div class="comments-list">
+23
-5
src/components/organisms/grain-engagement-bar.js
+23
-5
src/components/organisms/grain-engagement-bar.js
···
73
73
if (!auth.isAuthenticated || this._loading || !this.galleryUri) return;
74
74
75
75
this._loading = true;
76
+
77
+
// Store previous state for rollback
78
+
const previousState = {
79
+
viewerHasFavorited: this.viewerHasFavorited,
80
+
viewerFavoriteUri: this.viewerFavoriteUri,
81
+
favoriteCount: this.favoriteCount
82
+
};
83
+
84
+
// Optimistic update - apply immediately
85
+
this.viewerHasFavorited = !this.viewerHasFavorited;
86
+
this.favoriteCount += this.viewerHasFavorited ? 1 : -1;
87
+
if (!this.viewerHasFavorited) {
88
+
this.viewerFavoriteUri = null;
89
+
}
90
+
76
91
try {
77
92
const update = await mutations.toggleFavorite(
78
93
this.galleryUri,
79
-
this.viewerHasFavorited,
80
-
this.viewerFavoriteUri,
81
-
this.favoriteCount
94
+
previousState.viewerHasFavorited,
95
+
previousState.viewerFavoriteUri,
96
+
previousState.favoriteCount
82
97
);
83
-
this.viewerHasFavorited = update.viewerHasFavorited;
98
+
// Update with real URI from server (needed for future deletes)
84
99
this.viewerFavoriteUri = update.viewerFavoriteUri;
85
-
this.favoriteCount = update.favoriteCount;
86
100
} catch (err) {
101
+
// Rollback on failure
87
102
console.error('Failed to toggle favorite:', err);
103
+
this.viewerHasFavorited = previousState.viewerHasFavorited;
104
+
this.viewerFavoriteUri = previousState.viewerFavoriteUri;
105
+
this.favoriteCount = previousState.favoriteCount;
88
106
this.shadowRoot.querySelector('grain-toast').show('Failed to update');
89
107
} finally {
90
108
this._loading = false;
+46
-5
src/components/organisms/grain-gallery-card.js
+46
-5
src/components/organisms/grain-gallery-card.js
···
1
1
import { LitElement, html, css } from 'lit';
2
2
import { router } from '../../router.js';
3
+
import { auth } from '../../services/auth.js';
3
4
import '../molecules/grain-author-chip.js';
5
+
import '../atoms/grain-icon.js';
4
6
import './grain-image-carousel.js';
5
7
import './grain-engagement-bar.js';
6
8
···
54
56
.clickable {
55
57
cursor: pointer;
56
58
}
59
+
.header-row {
60
+
display: flex;
61
+
align-items: center;
62
+
justify-content: space-between;
63
+
}
64
+
.menu-button {
65
+
display: flex;
66
+
align-items: center;
67
+
justify-content: center;
68
+
background: none;
69
+
border: none;
70
+
padding: var(--space-sm);
71
+
margin-right: calc(-1 * var(--space-sm));
72
+
cursor: pointer;
73
+
color: var(--color-text-primary);
74
+
}
57
75
`;
58
76
77
+
get #isOwner() {
78
+
return auth.user?.handle === this.gallery?.handle;
79
+
}
80
+
81
+
#handleMenuOpen(e) {
82
+
e.stopPropagation();
83
+
this.dispatchEvent(new CustomEvent('open-gallery-menu', {
84
+
bubbles: true,
85
+
composed: true,
86
+
detail: {
87
+
gallery: this.gallery,
88
+
isOwner: this.#isOwner
89
+
}
90
+
}));
91
+
}
92
+
59
93
#formatDate(iso) {
60
94
const date = new Date(iso);
61
95
const now = new Date();
···
89
123
90
124
return html`
91
125
<header class="header">
92
-
<grain-author-chip
93
-
avatarUrl=${gallery.avatarUrl || ''}
94
-
handle=${gallery.handle}
95
-
displayName=${gallery.displayName || ''}
96
-
></grain-author-chip>
126
+
<div class="header-row">
127
+
<grain-author-chip
128
+
avatarUrl=${gallery.avatarUrl || ''}
129
+
handle=${gallery.handle}
130
+
displayName=${gallery.displayName || ''}
131
+
></grain-author-chip>
132
+
${auth.isAuthenticated ? html`
133
+
<button class="menu-button" @click=${this.#handleMenuOpen}>
134
+
<grain-icon name="ellipsis" size="20"></grain-icon>
135
+
</button>
136
+
` : ''}
137
+
</div>
97
138
</header>
98
139
99
140
<div class="clickable" @click=${this.#handleClick}>
+117
-3
src/components/organisms/grain-image-carousel.js
+117
-3
src/components/organisms/grain-image-carousel.js
···
1
1
import { LitElement, html, css } from 'lit';
2
2
import '../atoms/grain-image.js';
3
+
import '../atoms/grain-icon.js';
4
+
import '../atoms/grain-alt-badge.js';
3
5
import '../molecules/grain-carousel-dots.js';
4
6
5
7
export class GrainImageCarousel extends LitElement {
6
8
static properties = {
7
9
photos: { type: Array },
8
10
rkey: { type: String },
9
-
_currentIndex: { state: true }
11
+
_currentIndex: { state: true },
12
+
_activeAltIndex: { state: true }
10
13
};
11
14
12
15
static styles = css`
···
27
30
.slide {
28
31
flex: 0 0 100%;
29
32
scroll-snap-align: start;
33
+
position: relative;
30
34
}
31
35
.slide.centered {
32
36
display: flex;
···
42
46
left: 0;
43
47
right: 0;
44
48
}
49
+
.nav-arrow {
50
+
position: absolute;
51
+
top: 50%;
52
+
transform: translateY(-50%);
53
+
width: 24px;
54
+
height: 24px;
55
+
border-radius: 50%;
56
+
border: none;
57
+
background: rgba(255, 255, 255, 0.7);
58
+
color: rgba(120, 100, 90, 1);
59
+
cursor: pointer;
60
+
display: flex;
61
+
align-items: center;
62
+
justify-content: center;
63
+
padding: 0;
64
+
z-index: 1;
65
+
}
66
+
.nav-arrow:hover {
67
+
background: rgba(255, 255, 255, 1);
68
+
}
69
+
.nav-arrow:focus {
70
+
outline: none;
71
+
}
72
+
.nav-arrow:focus-visible {
73
+
outline: 2px solid rgba(120, 100, 90, 0.5);
74
+
outline-offset: 2px;
75
+
}
76
+
.nav-arrow-left {
77
+
left: 8px;
78
+
}
79
+
.nav-arrow-right {
80
+
right: 8px;
81
+
}
82
+
.alt-overlay {
83
+
position: absolute;
84
+
inset: 0;
85
+
background: rgba(0, 0, 0, 0.75);
86
+
color: white;
87
+
padding: 16px;
88
+
font-size: 14px;
89
+
line-height: 1.5;
90
+
overflow-y: auto;
91
+
display: flex;
92
+
align-items: center;
93
+
justify-content: center;
94
+
text-align: center;
95
+
box-sizing: border-box;
96
+
z-index: 3;
97
+
cursor: pointer;
98
+
}
45
99
`;
46
100
47
101
constructor() {
48
102
super();
49
103
this.photos = [];
50
104
this._currentIndex = 0;
105
+
this._activeAltIndex = null;
51
106
}
52
107
53
108
get #hasPortrait() {
···
64
119
const index = Math.round(carousel.scrollLeft / carousel.offsetWidth);
65
120
if (index !== this._currentIndex) {
66
121
this._currentIndex = index;
122
+
this._activeAltIndex = null;
123
+
}
124
+
}
125
+
126
+
#handleAltClick(e, index) {
127
+
e.stopPropagation();
128
+
this._activeAltIndex = index;
129
+
}
130
+
131
+
#handleOverlayClick(e) {
132
+
e.stopPropagation();
133
+
this._activeAltIndex = null;
134
+
}
135
+
136
+
#goToPrevious(e) {
137
+
e.stopPropagation();
138
+
if (this._currentIndex > 0) {
139
+
const carousel = this.shadowRoot.querySelector('.carousel');
140
+
const slides = carousel.querySelectorAll('.slide');
141
+
slides[this._currentIndex - 1].scrollIntoView({
142
+
behavior: 'smooth',
143
+
block: 'nearest',
144
+
inline: 'start'
145
+
});
146
+
}
147
+
}
148
+
149
+
#goToNext(e) {
150
+
e.stopPropagation();
151
+
if (this._currentIndex < this.photos.length - 1) {
152
+
const carousel = this.shadowRoot.querySelector('.carousel');
153
+
const slides = carousel.querySelectorAll('.slide');
154
+
slides[this._currentIndex + 1].scrollIntoView({
155
+
behavior: 'smooth',
156
+
block: 'nearest',
157
+
inline: 'start'
158
+
});
67
159
}
68
160
}
69
161
···
79
171
render() {
80
172
const hasPortrait = this.#hasPortrait;
81
173
const minAspectRatio = this.#minAspectRatio;
82
-
83
-
// Calculate height based on tallest image when portrait exists
84
174
const carouselStyle = hasPortrait
85
175
? `aspect-ratio: ${minAspectRatio};`
86
176
: '';
87
177
178
+
const showLeftArrow = this.photos.length > 1 && this._currentIndex > 0;
179
+
const showRightArrow = this.photos.length > 1 && this._currentIndex < this.photos.length - 1;
180
+
88
181
return html`
89
182
<div class="carousel" style=${carouselStyle} @scroll=${this.#handleScroll}>
90
183
${this.photos.map((photo, index) => html`
···
95
188
aspectRatio=${photo.aspectRatio || 1}
96
189
style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''}
97
190
></grain-image>
191
+
${photo.alt ? html`
192
+
<grain-alt-badge
193
+
.alt=${photo.alt}
194
+
@alt-click=${(e) => this.#handleAltClick(e, index)}
195
+
></grain-alt-badge>
196
+
` : ''}
197
+
${this._activeAltIndex === index ? html`
198
+
<div class="alt-overlay" @click=${this.#handleOverlayClick}>
199
+
${photo.alt}
200
+
</div>
201
+
` : ''}
98
202
</div>
99
203
`)}
100
204
</div>
205
+
${showLeftArrow ? html`
206
+
<button class="nav-arrow nav-arrow-left" @click=${this.#goToPrevious} aria-label="Previous image">
207
+
<grain-icon name="chevronLeft" size="12"></grain-icon>
208
+
</button>
209
+
` : ''}
210
+
${showRightArrow ? html`
211
+
<button class="nav-arrow nav-arrow-right" @click=${this.#goToNext} aria-label="Next image">
212
+
<grain-icon name="chevronRight" size="12"></grain-icon>
213
+
</button>
214
+
` : ''}
101
215
${this.photos.length > 1 ? html`
102
216
<div class="dots">
103
217
<grain-carousel-dots
+29
-6
src/components/organisms/grain-profile-header.js
+29
-6
src/components/organisms/grain-profile-header.js
···
238
238
if (!this._user || this._followLoading || !this.profile) return;
239
239
240
240
this._followLoading = true;
241
+
242
+
// Store previous state for rollback
243
+
const previousState = {
244
+
viewerIsFollowing: this.profile.viewerIsFollowing,
245
+
viewerFollowUri: this.profile.viewerFollowUri,
246
+
followerCount: this.profile.followerCount || 0
247
+
};
248
+
249
+
// Optimistic update - apply immediately
250
+
const newIsFollowing = !previousState.viewerIsFollowing;
251
+
this.profile = {
252
+
...this.profile,
253
+
viewerIsFollowing: newIsFollowing,
254
+
viewerFollowUri: newIsFollowing ? this.profile.viewerFollowUri : null,
255
+
followerCount: previousState.followerCount + (newIsFollowing ? 1 : -1)
256
+
};
257
+
241
258
try {
242
259
const update = await mutations.toggleFollow(
243
260
this.profile.handle,
244
261
this.profile.did,
245
-
this.profile.viewerIsFollowing,
246
-
this.profile.viewerFollowUri,
247
-
this.profile.followerCount || 0
262
+
previousState.viewerIsFollowing,
263
+
previousState.viewerFollowUri,
264
+
previousState.followerCount
248
265
);
266
+
// Update with real URI from server (needed for future unfollows)
249
267
this.profile = {
250
268
...this.profile,
251
-
viewerIsFollowing: update.viewerIsFollowing,
252
-
viewerFollowUri: update.viewerFollowUri,
253
-
followerCount: update.followerCount
269
+
viewerFollowUri: update.viewerFollowUri
254
270
};
255
271
} catch (err) {
272
+
// Rollback on failure
256
273
console.error('Failed to toggle follow:', err);
274
+
this.profile = {
275
+
...this.profile,
276
+
viewerIsFollowing: previousState.viewerIsFollowing,
277
+
viewerFollowUri: previousState.viewerFollowUri,
278
+
followerCount: previousState.followerCount
279
+
};
257
280
this.shadowRoot.querySelector('grain-toast')?.show('Failed to update');
258
281
} finally {
259
282
this._followLoading = false;
+356
src/components/organisms/grain-report-dialog.js
+356
src/components/organisms/grain-report-dialog.js
···
1
+
import { LitElement, html, css } from 'lit';
2
+
import { mutations } from '../../services/mutations.js';
3
+
import '../atoms/grain-button.js';
4
+
import '../atoms/grain-spinner.js';
5
+
import '../atoms/grain-close-button.js';
6
+
7
+
const REPORT_REASONS = [
8
+
{ type: 'SPAM', label: 'Spam', description: 'Unwanted commercial content or repetitive posts' },
9
+
{ type: 'MISLEADING', label: 'Misleading', description: 'False or deceptive information' },
10
+
{ type: 'SEXUAL', label: 'Sexual content', description: 'Adult or inappropriate imagery' },
11
+
{ type: 'RUDE', label: 'Rude or offensive', description: 'Harassment, hate speech, or bullying' },
12
+
{ type: 'VIOLATION', label: 'Rule violation', description: 'Breaking community guidelines' },
13
+
{ type: 'OTHER', label: 'Other', description: 'Something else not listed above' }
14
+
];
15
+
16
+
export class GrainReportDialog extends LitElement {
17
+
static properties = {
18
+
open: { type: Boolean, reflect: true },
19
+
galleryUri: { type: String },
20
+
_selectedReason: { state: true },
21
+
_details: { state: true },
22
+
_submitting: { state: true },
23
+
_error: { state: true }
24
+
};
25
+
26
+
static styles = css`
27
+
:host {
28
+
display: none;
29
+
}
30
+
:host([open]) {
31
+
display: block;
32
+
}
33
+
.overlay {
34
+
position: fixed;
35
+
inset: 0;
36
+
background: rgba(0, 0, 0, 0.5);
37
+
display: flex;
38
+
align-items: center;
39
+
justify-content: center;
40
+
z-index: 1000;
41
+
padding: var(--space-md);
42
+
}
43
+
.dialog {
44
+
background: var(--color-bg-primary);
45
+
border: 1px solid var(--color-border);
46
+
border-radius: 12px;
47
+
width: 100%;
48
+
max-width: 400px;
49
+
max-height: 90vh;
50
+
display: flex;
51
+
flex-direction: column;
52
+
}
53
+
.header {
54
+
display: flex;
55
+
align-items: center;
56
+
justify-content: space-between;
57
+
padding: var(--space-md);
58
+
border-bottom: 1px solid var(--color-border);
59
+
font-weight: var(--font-weight-semibold);
60
+
font-size: var(--font-size-md);
61
+
}
62
+
.content {
63
+
flex: 1;
64
+
overflow-y: auto;
65
+
padding: var(--space-md);
66
+
}
67
+
.reason-card {
68
+
display: flex;
69
+
align-items: flex-start;
70
+
gap: var(--space-sm);
71
+
width: 100%;
72
+
padding: var(--space-sm) var(--space-md);
73
+
margin-bottom: var(--space-sm);
74
+
background: var(--color-bg-secondary);
75
+
border: 2px solid var(--color-border);
76
+
border-radius: var(--border-radius);
77
+
cursor: pointer;
78
+
text-align: left;
79
+
font-family: inherit;
80
+
}
81
+
.reason-card:hover {
82
+
border-color: var(--color-text-secondary);
83
+
}
84
+
.reason-card.selected {
85
+
border-color: var(--color-accent);
86
+
background: var(--color-bg-primary);
87
+
}
88
+
.radio {
89
+
flex-shrink: 0;
90
+
width: 18px;
91
+
height: 18px;
92
+
margin-top: 2px;
93
+
border: 2px solid var(--color-border);
94
+
border-radius: 50%;
95
+
display: flex;
96
+
align-items: center;
97
+
justify-content: center;
98
+
}
99
+
.reason-card.selected .radio {
100
+
border-color: var(--color-accent);
101
+
}
102
+
.radio-dot {
103
+
width: 10px;
104
+
height: 10px;
105
+
border-radius: 50%;
106
+
background: var(--color-accent);
107
+
display: none;
108
+
}
109
+
.reason-card.selected .radio-dot {
110
+
display: block;
111
+
}
112
+
.reason-content {
113
+
flex: 1;
114
+
}
115
+
.reason-label {
116
+
font-size: var(--font-size-sm);
117
+
font-weight: var(--font-weight-medium);
118
+
color: var(--color-text-primary);
119
+
}
120
+
.reason-description {
121
+
font-size: var(--font-size-xs);
122
+
color: var(--color-text-secondary);
123
+
margin-top: var(--space-xs);
124
+
}
125
+
.details-section {
126
+
margin-top: var(--space-md);
127
+
}
128
+
.details-label {
129
+
font-size: var(--font-size-sm);
130
+
color: var(--color-text-secondary);
131
+
margin-bottom: var(--space-sm);
132
+
}
133
+
.details-textarea {
134
+
width: 100%;
135
+
min-height: 80px;
136
+
padding: var(--space-sm) var(--space-md);
137
+
border: 1px solid var(--color-border);
138
+
border-radius: var(--border-radius);
139
+
font-family: inherit;
140
+
font-size: var(--font-size-sm);
141
+
resize: vertical;
142
+
background: var(--color-bg-secondary);
143
+
color: var(--color-text-primary);
144
+
box-sizing: border-box;
145
+
}
146
+
.details-textarea:focus {
147
+
outline: none;
148
+
border-color: var(--color-accent);
149
+
}
150
+
.char-count {
151
+
font-size: var(--font-size-xs);
152
+
color: var(--color-text-secondary);
153
+
text-align: right;
154
+
margin-top: var(--space-xs);
155
+
}
156
+
.error {
157
+
color: var(--color-error);
158
+
font-size: var(--font-size-sm);
159
+
margin-top: var(--space-sm);
160
+
padding: var(--space-sm) var(--space-md);
161
+
background: rgba(255, 68, 68, 0.1);
162
+
border-radius: var(--border-radius);
163
+
}
164
+
.footer {
165
+
display: flex;
166
+
gap: var(--space-sm);
167
+
padding: var(--space-md);
168
+
border-top: 1px solid var(--color-border);
169
+
}
170
+
.footer button {
171
+
flex: 1;
172
+
padding: var(--space-sm) var(--space-md);
173
+
border-radius: var(--border-radius);
174
+
font-family: inherit;
175
+
font-size: var(--font-size-sm);
176
+
font-weight: var(--font-weight-medium);
177
+
cursor: pointer;
178
+
}
179
+
.cancel-button {
180
+
background: var(--color-bg-secondary);
181
+
border: 1px solid var(--color-border);
182
+
color: var(--color-text-primary);
183
+
}
184
+
.submit-button {
185
+
background: var(--color-accent);
186
+
border: none;
187
+
color: white;
188
+
display: flex;
189
+
align-items: center;
190
+
justify-content: center;
191
+
gap: 8px;
192
+
}
193
+
.submit-button:disabled {
194
+
opacity: 0.5;
195
+
cursor: not-allowed;
196
+
}
197
+
`;
198
+
199
+
#boundHandleKeydown = null;
200
+
201
+
constructor() {
202
+
super();
203
+
this.open = false;
204
+
this.galleryUri = '';
205
+
this._selectedReason = null;
206
+
this._details = '';
207
+
this._submitting = false;
208
+
this._error = null;
209
+
this.#boundHandleKeydown = this.#handleKeydown.bind(this);
210
+
}
211
+
212
+
connectedCallback() {
213
+
super.connectedCallback();
214
+
document.addEventListener('keydown', this.#boundHandleKeydown);
215
+
}
216
+
217
+
disconnectedCallback() {
218
+
document.removeEventListener('keydown', this.#boundHandleKeydown);
219
+
super.disconnectedCallback();
220
+
}
221
+
222
+
#handleKeydown(e) {
223
+
if (e.key === 'Escape' && this.open && !this._submitting) {
224
+
this.#close();
225
+
}
226
+
}
227
+
228
+
updated(changedProperties) {
229
+
if (changedProperties.has('open') && this.open) {
230
+
this.#reset();
231
+
// Focus first reason card when dialog opens
232
+
requestAnimationFrame(() => {
233
+
this.shadowRoot.querySelector('.reason-card')?.focus();
234
+
});
235
+
}
236
+
}
237
+
238
+
#reset() {
239
+
this._selectedReason = null;
240
+
this._details = '';
241
+
this._submitting = false;
242
+
this._error = null;
243
+
}
244
+
245
+
#handleOverlayClick(e) {
246
+
if (e.target.classList.contains('overlay') && !this._submitting) {
247
+
this.#close();
248
+
}
249
+
}
250
+
251
+
#close() {
252
+
this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }));
253
+
}
254
+
255
+
#selectReason(type) {
256
+
this._selectedReason = type;
257
+
this._error = null;
258
+
}
259
+
260
+
#handleDetailsInput(e) {
261
+
const value = e.target.value;
262
+
if (value.length <= 300) {
263
+
this._details = value;
264
+
}
265
+
}
266
+
267
+
async #submit() {
268
+
if (!this._selectedReason || this._submitting) return;
269
+
270
+
this._submitting = true;
271
+
this._error = null;
272
+
273
+
try {
274
+
await mutations.createReport(
275
+
this.galleryUri,
276
+
this._selectedReason,
277
+
this._details || null
278
+
);
279
+
280
+
this.dispatchEvent(new CustomEvent('submitted', { bubbles: true, composed: true }));
281
+
this.#close();
282
+
} catch (err) {
283
+
console.error('Failed to submit report:', err);
284
+
this._error = 'Failed to submit report. Please try again.';
285
+
} finally {
286
+
this._submitting = false;
287
+
}
288
+
}
289
+
290
+
render() {
291
+
return html`
292
+
<div class="overlay" @click=${this.#handleOverlayClick}>
293
+
<div class="dialog">
294
+
<div class="header">
295
+
<span>Report gallery</span>
296
+
<grain-close-button @close=${this.#close}></grain-close-button>
297
+
</div>
298
+
299
+
<div class="content">
300
+
${REPORT_REASONS.map(reason => html`
301
+
<button
302
+
class="reason-card ${this._selectedReason === reason.type ? 'selected' : ''}"
303
+
@click=${() => this.#selectReason(reason.type)}
304
+
?disabled=${this._submitting}
305
+
>
306
+
<div class="radio">
307
+
<div class="radio-dot"></div>
308
+
</div>
309
+
<div class="reason-content">
310
+
<div class="reason-label">${reason.label}</div>
311
+
<div class="reason-description">${reason.description}</div>
312
+
</div>
313
+
</button>
314
+
`)}
315
+
316
+
<div class="details-section">
317
+
<div class="details-label">Add details (optional)</div>
318
+
<textarea
319
+
class="details-textarea"
320
+
placeholder="Provide additional context..."
321
+
.value=${this._details}
322
+
@input=${this.#handleDetailsInput}
323
+
?disabled=${this._submitting}
324
+
></textarea>
325
+
<div class="char-count">${this._details.length}/300</div>
326
+
</div>
327
+
328
+
${this._error ? html`
329
+
<div class="error">${this._error}</div>
330
+
` : ''}
331
+
</div>
332
+
333
+
<div class="footer">
334
+
<button
335
+
class="cancel-button"
336
+
@click=${this.#close}
337
+
?disabled=${this._submitting}
338
+
>
339
+
Cancel
340
+
</button>
341
+
<button
342
+
class="submit-button"
343
+
@click=${this.#submit}
344
+
?disabled=${!this._selectedReason || this._submitting}
345
+
>
346
+
${this._submitting ? html`<grain-spinner size="16"></grain-spinner>` : ''}
347
+
Submit
348
+
</button>
349
+
</div>
350
+
</div>
351
+
</div>
352
+
`;
353
+
}
354
+
}
355
+
356
+
customElements.define('grain-report-dialog', GrainReportDialog);
+87
-3
src/components/pages/grain-app.js
+87
-3
src/components/pages/grain-app.js
···
1
1
import { LitElement, html, css } from 'lit';
2
2
import { router } from '../../router.js';
3
+
import '../organisms/grain-action-dialog.js';
4
+
import '../organisms/grain-report-dialog.js';
5
+
import '../atoms/grain-toast.js';
3
6
4
7
// Import pages
5
8
import './grain-timeline.js';
···
10
13
import './grain-settings.js';
11
14
import './grain-edit-profile.js';
12
15
import './grain-create-gallery.js';
16
+
import './grain-image-descriptions.js';
13
17
import './grain-explore.js';
14
18
import './grain-notifications.js';
15
19
import './grain-terms.js';
16
20
import './grain-privacy.js';
17
21
import './grain-copyright.js';
22
+
import './grain-oauth-callback.js';
23
+
import './grain-onboarding.js';
18
24
import '../organisms/grain-header.js';
19
25
import '../organisms/grain-bottom-nav.js';
20
26
21
27
export class GrainApp extends LitElement {
28
+
static properties = {
29
+
_dialogType: { state: true },
30
+
_dialogProps: { state: true }
31
+
};
32
+
22
33
static styles = css`
23
34
:host {
24
35
display: block;
···
46
57
}
47
58
`;
48
59
60
+
constructor() {
61
+
super();
62
+
this._dialogType = null;
63
+
this._dialogProps = {};
64
+
}
65
+
66
+
connectedCallback() {
67
+
super.connectedCallback();
68
+
this.addEventListener('open-dialog', this.#handleOpenDialog);
69
+
this.addEventListener('close-dialog', this.#closeDialog);
70
+
}
71
+
72
+
disconnectedCallback() {
73
+
this.removeEventListener('open-dialog', this.#handleOpenDialog);
74
+
this.removeEventListener('close-dialog', this.#closeDialog);
75
+
super.disconnectedCallback();
76
+
}
77
+
78
+
#handleOpenDialog = (e) => {
79
+
this._dialogType = e.detail.type;
80
+
this._dialogProps = e.detail.props || {};
81
+
};
82
+
83
+
#closeDialog = () => {
84
+
this._dialogType = null;
85
+
this._dialogProps = {};
86
+
};
87
+
88
+
#handleReportSubmitted = () => {
89
+
this.#closeDialog();
90
+
this.shadowRoot.querySelector('grain-toast')?.show('Report submitted');
91
+
};
92
+
93
+
#handleDialogAction = (e) => {
94
+
this.dispatchEvent(new CustomEvent('dialog-action', {
95
+
bubbles: true,
96
+
composed: true,
97
+
detail: e.detail
98
+
}));
99
+
};
100
+
101
+
#renderDialog() {
102
+
switch (this._dialogType) {
103
+
case 'report':
104
+
return html`
105
+
<grain-report-dialog
106
+
open
107
+
galleryUri=${this._dialogProps.galleryUri || ''}
108
+
@close=${this.#closeDialog}
109
+
@submitted=${this.#handleReportSubmitted}
110
+
></grain-report-dialog>
111
+
`;
112
+
case 'action':
113
+
return html`
114
+
<grain-action-dialog
115
+
open
116
+
.actions=${this._dialogProps.actions || []}
117
+
?loading=${this._dialogProps.loading}
118
+
loadingText=${this._dialogProps.loadingText || ''}
119
+
@close=${this.#closeDialog}
120
+
@action=${this.#handleDialogAction}
121
+
></grain-action-dialog>
122
+
`;
123
+
default:
124
+
return '';
125
+
}
126
+
}
127
+
49
128
firstUpdated() {
50
129
const outlet = this.shadowRoot.getElementById('outlet');
51
130
···
57
136
.register('/profile/:handle', 'grain-profile')
58
137
.register('/settings', 'grain-settings')
59
138
.register('/settings/profile', 'grain-edit-profile')
60
-
.register('/settings/terms', 'grain-terms')
61
-
.register('/settings/privacy', 'grain-privacy')
62
-
.register('/settings/copyright', 'grain-copyright')
139
+
.register('/legal/terms', 'grain-terms')
140
+
.register('/legal/privacy', 'grain-privacy')
141
+
.register('/legal/copyright', 'grain-copyright')
63
142
.register('/create', 'grain-create-gallery')
143
+
.register('/create/descriptions', 'grain-image-descriptions')
64
144
.register('/explore', 'grain-explore')
65
145
.register('/notifications', 'grain-notifications')
146
+
.register('/onboarding', 'grain-onboarding')
147
+
.register('/oauth/callback', 'grain-oauth-callback')
66
148
.register('*', 'grain-timeline')
67
149
.connect(outlet);
68
150
}
···
72
154
<grain-header></grain-header>
73
155
<div id="outlet"></div>
74
156
<grain-bottom-nav></grain-bottom-nav>
157
+
${this.#renderDialog()}
158
+
<grain-toast></grain-toast>
75
159
`;
76
160
}
77
161
}
+28
-142
src/components/pages/grain-create-gallery.js
+28
-142
src/components/pages/grain-create-gallery.js
···
2
2
import { router } from '../../router.js';
3
3
import { auth } from '../../services/auth.js';
4
4
import { draftGallery } from '../../services/draft-gallery.js';
5
-
import { parseTextToFacets } from '../../lib/richtext.js';
6
-
import { grainApi } from '../../services/grain-api.js';
7
5
import '../atoms/grain-icon.js';
8
6
import '../atoms/grain-button.js';
9
7
import '../atoms/grain-input.js';
10
8
import '../atoms/grain-textarea.js';
11
9
import '../molecules/grain-form-field.js';
12
10
13
-
const UPLOAD_BLOB_MUTATION = `
14
-
mutation UploadBlob($data: String!, $mimeType: String!) {
15
-
uploadBlob(data: $data, mimeType: $mimeType) {
16
-
ref
17
-
mimeType
18
-
size
19
-
}
20
-
}
21
-
`;
22
-
23
-
const CREATE_PHOTO_MUTATION = `
24
-
mutation CreatePhoto($input: SocialGrainPhotoInput!) {
25
-
createSocialGrainPhoto(input: $input) {
26
-
uri
27
-
}
28
-
}
29
-
`;
30
-
31
-
const CREATE_GALLERY_MUTATION = `
32
-
mutation CreateGallery($input: SocialGrainGalleryInput!) {
33
-
createSocialGrainGallery(input: $input) {
34
-
uri
35
-
}
36
-
}
37
-
`;
38
-
39
-
const CREATE_GALLERY_ITEM_MUTATION = `
40
-
mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) {
41
-
createSocialGrainGalleryItem(input: $input) {
42
-
uri
43
-
}
44
-
}
45
-
`;
46
-
47
11
export class GrainCreateGallery extends LitElement {
48
12
static properties = {
49
13
_photos: { state: true },
50
14
_title: { state: true },
51
-
_description: { state: true },
52
-
_posting: { state: true },
53
-
_error: { state: true }
15
+
_description: { state: true }
54
16
};
55
17
56
18
static styles = css`
···
122
84
.form {
123
85
padding: var(--space-sm);
124
86
}
125
-
.error {
126
-
color: #ff4444;
127
-
padding: var(--space-sm);
128
-
text-align: center;
129
-
}
130
87
`;
131
88
132
89
constructor() {
···
134
91
this._photos = [];
135
92
this._title = '';
136
93
this._description = '';
137
-
this._posting = false;
138
-
this._error = null;
139
94
}
140
95
141
96
connectedCallback() {
142
97
super.connectedCallback();
98
+
99
+
if (!auth.isAuthenticated) {
100
+
router.replace('/');
101
+
return;
102
+
}
103
+
143
104
this._photos = draftGallery.getPhotos();
105
+
106
+
// Restore title/description if returning from descriptions page
107
+
this._title = sessionStorage.getItem('draft_title') || '';
108
+
this._description = sessionStorage.getItem('draft_description') || '';
109
+
144
110
if (!this._photos.length) {
145
111
router.push('/');
146
112
}
···
149
115
#handleBack() {
150
116
if (confirm('Discard this gallery?')) {
151
117
draftGallery.clear();
118
+
sessionStorage.removeItem('draft_title');
119
+
sessionStorage.removeItem('draft_description');
152
120
history.back();
153
121
}
154
122
}
155
123
156
124
#removePhoto(index) {
157
125
this._photos = this._photos.filter((_, i) => i !== index);
126
+
draftGallery.setPhotos(this._photos);
158
127
if (this._photos.length === 0) {
159
128
draftGallery.clear();
129
+
sessionStorage.removeItem('draft_title');
130
+
sessionStorage.removeItem('draft_description');
160
131
router.push('/');
161
132
}
162
133
}
···
169
140
this._description = e.detail.value.slice(0, 1000);
170
141
}
171
142
172
-
get #canPost() {
173
-
return this._title.trim().length > 0 && this._photos.length > 0 && !this._posting;
143
+
get #canProceed() {
144
+
return this._title.trim().length > 0 && this._photos.length > 0;
174
145
}
175
146
176
-
async #handlePost() {
177
-
if (!this.#canPost) return;
178
-
179
-
this._posting = true;
180
-
this._error = null;
181
-
182
-
try {
183
-
const client = auth.getClient();
184
-
const now = new Date().toISOString();
185
-
186
-
// Upload photos and create photo records
187
-
const photoUris = [];
188
-
for (const photo of this._photos) {
189
-
// Upload blob
190
-
const base64Data = photo.dataUrl.split(',')[1];
191
-
const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, {
192
-
data: base64Data,
193
-
mimeType: 'image/jpeg'
194
-
});
147
+
#handleNext() {
148
+
if (!this.#canProceed) return;
195
149
196
-
if (!uploadResult.uploadBlob) {
197
-
throw new Error('Failed to upload image');
198
-
}
150
+
sessionStorage.setItem('draft_title', this._title);
151
+
sessionStorage.setItem('draft_description', this._description);
152
+
draftGallery.setPhotos(this._photos);
199
153
200
-
// Create photo record
201
-
const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, {
202
-
input: {
203
-
photo: {
204
-
$type: 'blob',
205
-
ref: { $link: uploadResult.uploadBlob.ref },
206
-
mimeType: uploadResult.uploadBlob.mimeType,
207
-
size: uploadResult.uploadBlob.size
208
-
},
209
-
aspectRatio: {
210
-
width: photo.width,
211
-
height: photo.height
212
-
},
213
-
createdAt: now
214
-
}
215
-
});
216
-
217
-
photoUris.push(photoResult.createSocialGrainPhoto.uri);
218
-
}
219
-
220
-
// Parse description for facets
221
-
let facets = null;
222
-
if (this._description.trim()) {
223
-
const resolveHandle = async (handle) => grainApi.resolveHandle(handle);
224
-
const parsed = await parseTextToFacets(this._description.trim(), resolveHandle);
225
-
if (parsed.facets.length > 0) {
226
-
facets = parsed.facets;
227
-
}
228
-
}
229
-
230
-
// Create gallery record
231
-
const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, {
232
-
input: {
233
-
title: this._title.trim(),
234
-
...(this._description.trim() && { description: this._description.trim() }),
235
-
...(facets && { facets }),
236
-
createdAt: now
237
-
}
238
-
});
239
-
240
-
const galleryUri = galleryResult.createSocialGrainGallery.uri;
241
-
242
-
// Create gallery items linking photos to gallery
243
-
for (let i = 0; i < photoUris.length; i++) {
244
-
await client.mutate(CREATE_GALLERY_ITEM_MUTATION, {
245
-
input: {
246
-
gallery: galleryUri,
247
-
item: photoUris[i],
248
-
position: i,
249
-
createdAt: now
250
-
}
251
-
});
252
-
}
253
-
254
-
// Clear draft and navigate to new gallery
255
-
draftGallery.clear();
256
-
const rkey = galleryUri.split('/').pop();
257
-
router.push(`/profile/${auth.user.handle}/gallery/${rkey}`);
258
-
259
-
} catch (err) {
260
-
console.error('Failed to create gallery:', err);
261
-
this._error = err.message || 'Failed to create gallery. Please try again.';
262
-
} finally {
263
-
this._posting = false;
264
-
}
154
+
router.push('/create/descriptions');
265
155
}
266
156
267
157
render() {
···
274
164
<span class="header-title">Create a gallery</span>
275
165
</div>
276
166
<grain-button
277
-
?disabled=${!this.#canPost}
278
-
?loading=${this._posting}
279
-
loadingText="Posting..."
280
-
@click=${this.#handlePost}
281
-
>Post</grain-button>
167
+
?disabled=${!this.#canProceed}
168
+
@click=${this.#handleNext}
169
+
>Next</grain-button>
282
170
</div>
283
171
284
172
<div class="photo-strip">
···
289
177
</div>
290
178
`)}
291
179
</div>
292
-
293
-
${this._error ? html`<p class="error">${this._error}</p>` : ''}
294
180
295
181
<div class="form">
296
182
<grain-form-field .value=${this._title} .maxlength=${100}>
+7
src/components/pages/grain-edit-profile.js
+7
src/components/pages/grain-edit-profile.js
+45
-27
src/components/pages/grain-gallery-detail.js
+45
-27
src/components/pages/grain-gallery-detail.js
···
12
12
import '../atoms/grain-spinner.js';
13
13
import '../atoms/grain-icon.js';
14
14
import '../atoms/grain-rich-text.js';
15
-
import '../organisms/grain-action-dialog.js';
16
15
17
16
const DELETE_GALLERY_MUTATION = `
18
17
mutation DeleteGallery($rkey: String!) {
···
51
50
_gallery: { state: true },
52
51
_loading: { state: true },
53
52
_error: { state: true },
54
-
_menuOpen: { state: true },
55
-
_deleting: { state: true },
56
53
_commentSheetOpen: { state: true },
57
54
_focusPhotoUri: { state: true },
58
55
_focusPhotoUrl: { state: true }
···
139
136
this._gallery = null;
140
137
this._loading = true;
141
138
this._error = null;
142
-
this._menuOpen = false;
143
-
this._deleting = false;
144
139
this._commentSheetOpen = false;
145
140
this._focusPhotoUri = null;
146
141
this._focusPhotoUrl = null;
···
149
144
connectedCallback() {
150
145
super.connectedCallback();
151
146
this.#loadGallery();
147
+
document.addEventListener('dialog-action', this.#handleDialogAction);
152
148
}
153
149
154
150
disconnectedCallback() {
155
151
if (this.#currentUri) {
156
152
recordCache.unsubscribe(this.#currentUri, this.#onCacheUpdate);
157
153
}
154
+
document.removeEventListener('dialog-action', this.#handleDialogAction);
158
155
super.disconnectedCallback();
159
156
}
160
157
···
304
301
}
305
302
306
303
#handleMenuOpen() {
307
-
this._menuOpen = true;
308
-
}
309
-
310
-
#handleMenuClose() {
311
-
this._menuOpen = false;
304
+
this.dispatchEvent(new CustomEvent('open-dialog', {
305
+
bubbles: true,
306
+
composed: true,
307
+
detail: {
308
+
type: 'action',
309
+
props: {
310
+
actions: this.#isOwner
311
+
? [{ label: 'Delete', action: 'delete', danger: true }]
312
+
: [{ label: 'Report gallery', action: 'report' }]
313
+
}
314
+
}
315
+
}));
312
316
}
313
317
314
-
async #handleAction(e) {
318
+
#handleDialogAction = (e) => {
315
319
if (e.detail.action === 'delete') {
316
-
await this.#handleDelete();
320
+
this.#handleDelete();
321
+
} else if (e.detail.action === 'report') {
322
+
this.dispatchEvent(new CustomEvent('open-dialog', {
323
+
bubbles: true,
324
+
composed: true,
325
+
detail: {
326
+
type: 'report',
327
+
props: { galleryUri: this._gallery?.uri }
328
+
}
329
+
}));
317
330
}
318
-
}
331
+
};
319
332
320
333
async #handleDelete() {
321
-
this._deleting = true;
334
+
// Show loading state
335
+
this.dispatchEvent(new CustomEvent('open-dialog', {
336
+
bubbles: true,
337
+
composed: true,
338
+
detail: {
339
+
type: 'action',
340
+
props: {
341
+
actions: [{ label: 'Delete', action: 'delete', danger: true }],
342
+
loading: true,
343
+
loadingText: 'Deleting...'
344
+
}
345
+
}
346
+
}));
347
+
322
348
try {
323
349
const client = auth.getClient();
324
350
···
340
366
341
367
// Delete the gallery
342
368
await client.mutate(DELETE_GALLERY_MUTATION, { rkey: this.rkey });
369
+
370
+
// Close dialog and navigate
371
+
this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true }));
343
372
router.push(`/profile/${this.handle}`);
344
373
} catch (err) {
345
374
console.error('Failed to delete gallery:', err);
346
375
this._error = 'Failed to delete gallery. Please try again.';
347
-
this._menuOpen = false;
348
-
} finally {
349
-
this._deleting = false;
376
+
this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true }));
350
377
}
351
378
}
352
379
···
358
385
<grain-icon name="back" size="20"></grain-icon>
359
386
</button>
360
387
<div class="header-spacer"></div>
361
-
${this.#isOwner ? html`
388
+
${auth.isAuthenticated ? html`
362
389
<button class="menu-button" @click=${this.#handleMenuOpen}>
363
390
<grain-icon name="ellipsis" size="20"></grain-icon>
364
391
</button>
···
403
430
<time class="timestamp">${this.#formatDate(this._gallery.createdAt)}</time>
404
431
</div>
405
432
` : ''}
406
-
407
-
<grain-action-dialog
408
-
?open=${this._menuOpen}
409
-
?loading=${this._deleting}
410
-
loadingText="Deleting..."
411
-
.actions=${[{ label: 'Delete', action: 'delete', danger: true }]}
412
-
@action=${this.#handleAction}
413
-
@close=${this.#handleMenuClose}
414
-
></grain-action-dialog>
415
433
416
434
<grain-comment-sheet
417
435
?open=${this._commentSheetOpen}
+290
src/components/pages/grain-image-descriptions.js
+290
src/components/pages/grain-image-descriptions.js
···
1
+
import { LitElement, html, css } from 'lit';
2
+
import { router } from '../../router.js';
3
+
import { auth } from '../../services/auth.js';
4
+
import { draftGallery } from '../../services/draft-gallery.js';
5
+
import { parseTextToFacets } from '../../lib/richtext.js';
6
+
import { grainApi } from '../../services/grain-api.js';
7
+
import '../atoms/grain-icon.js';
8
+
import '../atoms/grain-button.js';
9
+
import '../atoms/grain-textarea.js';
10
+
11
+
const UPLOAD_BLOB_MUTATION = `
12
+
mutation UploadBlob($data: String!, $mimeType: String!) {
13
+
uploadBlob(data: $data, mimeType: $mimeType) {
14
+
ref
15
+
mimeType
16
+
size
17
+
}
18
+
}
19
+
`;
20
+
21
+
const CREATE_PHOTO_MUTATION = `
22
+
mutation CreatePhoto($input: SocialGrainPhotoInput!) {
23
+
createSocialGrainPhoto(input: $input) {
24
+
uri
25
+
}
26
+
}
27
+
`;
28
+
29
+
const CREATE_GALLERY_MUTATION = `
30
+
mutation CreateGallery($input: SocialGrainGalleryInput!) {
31
+
createSocialGrainGallery(input: $input) {
32
+
uri
33
+
}
34
+
}
35
+
`;
36
+
37
+
const CREATE_GALLERY_ITEM_MUTATION = `
38
+
mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) {
39
+
createSocialGrainGalleryItem(input: $input) {
40
+
uri
41
+
}
42
+
}
43
+
`;
44
+
45
+
export class GrainImageDescriptions extends LitElement {
46
+
static properties = {
47
+
_photos: { state: true },
48
+
_title: { state: true },
49
+
_description: { state: true },
50
+
_posting: { state: true },
51
+
_error: { state: true }
52
+
};
53
+
54
+
static styles = css`
55
+
:host {
56
+
display: block;
57
+
width: 100%;
58
+
max-width: var(--feed-max-width);
59
+
min-height: 100%;
60
+
background: var(--color-bg-primary);
61
+
align-self: center;
62
+
}
63
+
.header {
64
+
display: flex;
65
+
align-items: center;
66
+
justify-content: space-between;
67
+
padding: var(--space-sm);
68
+
border-bottom: 1px solid var(--color-border);
69
+
}
70
+
.header-left {
71
+
display: flex;
72
+
align-items: center;
73
+
gap: var(--space-xs);
74
+
}
75
+
.back-button {
76
+
background: none;
77
+
border: none;
78
+
padding: 8px;
79
+
margin-left: -8px;
80
+
cursor: pointer;
81
+
color: var(--color-text-primary);
82
+
}
83
+
.header-title {
84
+
font-size: var(--font-size-md);
85
+
font-weight: 600;
86
+
}
87
+
.photo-list {
88
+
padding: var(--space-sm);
89
+
}
90
+
.photo-row {
91
+
display: flex;
92
+
gap: var(--space-sm);
93
+
margin-bottom: var(--space-md);
94
+
}
95
+
.photo-thumb {
96
+
flex-shrink: 0;
97
+
max-width: 80px;
98
+
max-height: 120px;
99
+
width: auto;
100
+
height: auto;
101
+
border-radius: 4px;
102
+
object-fit: contain;
103
+
}
104
+
.info {
105
+
margin: 0;
106
+
padding: var(--space-sm);
107
+
font-size: var(--font-size-sm);
108
+
color: var(--color-text-secondary);
109
+
border-bottom: 1px solid var(--color-border);
110
+
}
111
+
.alt-input {
112
+
flex: 1;
113
+
}
114
+
.alt-input grain-textarea {
115
+
--textarea-min-height: 60px;
116
+
}
117
+
.alt-input grain-textarea::part(textarea) {
118
+
min-height: 60px;
119
+
}
120
+
.error {
121
+
color: #ff4444;
122
+
padding: var(--space-sm);
123
+
text-align: center;
124
+
}
125
+
`;
126
+
127
+
constructor() {
128
+
super();
129
+
this._photos = [];
130
+
this._title = '';
131
+
this._description = '';
132
+
this._posting = false;
133
+
this._error = null;
134
+
}
135
+
136
+
connectedCallback() {
137
+
super.connectedCallback();
138
+
139
+
if (!auth.isAuthenticated) {
140
+
router.replace('/');
141
+
return;
142
+
}
143
+
144
+
this._photos = draftGallery.getPhotos();
145
+
this._title = sessionStorage.getItem('draft_title') || '';
146
+
this._description = sessionStorage.getItem('draft_description') || '';
147
+
148
+
if (!this._photos.length) {
149
+
router.push('/');
150
+
}
151
+
}
152
+
153
+
#handleBack() {
154
+
router.push('/create');
155
+
}
156
+
157
+
#handleAltChange(index, e) {
158
+
const alt = e.detail.value;
159
+
draftGallery.updatePhotoAlt(index, alt);
160
+
}
161
+
162
+
async #handlePost() {
163
+
if (this._posting) return;
164
+
165
+
// Refresh photos from draftGallery to get latest alt text values
166
+
this._photos = draftGallery.getPhotos();
167
+
this._posting = true;
168
+
this._error = null;
169
+
170
+
try {
171
+
const client = auth.getClient();
172
+
const now = new Date().toISOString();
173
+
174
+
const photoUris = [];
175
+
for (const photo of this._photos) {
176
+
const base64Data = photo.dataUrl.split(',')[1];
177
+
const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, {
178
+
data: base64Data,
179
+
mimeType: 'image/jpeg'
180
+
});
181
+
182
+
if (!uploadResult.uploadBlob) {
183
+
throw new Error('Failed to upload image');
184
+
}
185
+
186
+
const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, {
187
+
input: {
188
+
photo: {
189
+
$type: 'blob',
190
+
ref: { $link: uploadResult.uploadBlob.ref },
191
+
mimeType: uploadResult.uploadBlob.mimeType,
192
+
size: uploadResult.uploadBlob.size
193
+
},
194
+
aspectRatio: {
195
+
width: photo.width,
196
+
height: photo.height
197
+
},
198
+
...(photo.alt && { alt: photo.alt }),
199
+
createdAt: now
200
+
}
201
+
});
202
+
203
+
photoUris.push(photoResult.createSocialGrainPhoto.uri);
204
+
}
205
+
206
+
let facets = null;
207
+
if (this._description.trim()) {
208
+
const resolveHandle = async (handle) => grainApi.resolveHandle(handle);
209
+
const parsed = await parseTextToFacets(this._description.trim(), resolveHandle);
210
+
if (parsed.facets.length > 0) {
211
+
facets = parsed.facets;
212
+
}
213
+
}
214
+
215
+
const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, {
216
+
input: {
217
+
title: this._title.trim(),
218
+
...(this._description.trim() && { description: this._description.trim() }),
219
+
...(facets && { facets }),
220
+
createdAt: now
221
+
}
222
+
});
223
+
224
+
const galleryUri = galleryResult.createSocialGrainGallery.uri;
225
+
226
+
for (let i = 0; i < photoUris.length; i++) {
227
+
await client.mutate(CREATE_GALLERY_ITEM_MUTATION, {
228
+
input: {
229
+
gallery: galleryUri,
230
+
item: photoUris[i],
231
+
position: i,
232
+
createdAt: now
233
+
}
234
+
});
235
+
}
236
+
237
+
draftGallery.clear();
238
+
sessionStorage.removeItem('draft_title');
239
+
sessionStorage.removeItem('draft_description');
240
+
const rkey = galleryUri.split('/').pop();
241
+
router.push(`/profile/${auth.user.handle}/gallery/${rkey}`);
242
+
243
+
} catch (err) {
244
+
console.error('Failed to create gallery:', err);
245
+
this._error = err.message || 'Failed to create gallery. Please try again.';
246
+
} finally {
247
+
this._posting = false;
248
+
}
249
+
}
250
+
251
+
render() {
252
+
return html`
253
+
<div class="header">
254
+
<div class="header-left">
255
+
<button class="back-button" @click=${this.#handleBack}>
256
+
<grain-icon name="back" size="20"></grain-icon>
257
+
</button>
258
+
<span class="header-title">Add image descriptions</span>
259
+
</div>
260
+
<grain-button
261
+
?loading=${this._posting}
262
+
loadingText="Posting..."
263
+
@click=${this.#handlePost}
264
+
>Post</grain-button>
265
+
</div>
266
+
267
+
${this._error ? html`<p class="error">${this._error}</p>` : ''}
268
+
269
+
<p class="info">Alt text describes images for blind and low-vision users, and helps give context to everyone.</p>
270
+
271
+
<div class="photo-list">
272
+
${this._photos.map((photo, i) => html`
273
+
<div class="photo-row">
274
+
<img class="photo-thumb" src=${photo.dataUrl} alt="Photo ${i + 1}">
275
+
<div class="alt-input">
276
+
<grain-textarea
277
+
placeholder="Alt text"
278
+
.value=${photo.alt || ''}
279
+
.maxlength=${1000}
280
+
@input=${(e) => this.#handleAltChange(i, e)}
281
+
></grain-textarea>
282
+
</div>
283
+
</div>
284
+
`)}
285
+
</div>
286
+
`;
287
+
}
288
+
}
289
+
290
+
customElements.define('grain-image-descriptions', GrainImageDescriptions);
+7
src/components/pages/grain-notifications.js
+7
src/components/pages/grain-notifications.js
···
143
143
144
144
connectedCallback() {
145
145
super.connectedCallback();
146
+
147
+
// Redirect to timeline if not authenticated
148
+
if (!auth.isAuthenticated) {
149
+
router.replace('/');
150
+
return;
151
+
}
152
+
146
153
this._unsubscribe = auth.subscribe(user => {
147
154
this._user = user;
148
155
if (user) {
+38
src/components/pages/grain-oauth-callback.js
+38
src/components/pages/grain-oauth-callback.js
···
1
+
import { LitElement, html, css } from 'lit';
2
+
import { auth } from '../../services/auth.js';
3
+
import '../atoms/grain-spinner.js';
4
+
5
+
export class GrainOAuthCallback extends LitElement {
6
+
static styles = css`
7
+
:host {
8
+
display: flex;
9
+
flex-direction: column;
10
+
align-items: center;
11
+
justify-content: center;
12
+
min-height: 100%;
13
+
gap: var(--space-md);
14
+
}
15
+
p {
16
+
color: var(--color-text-secondary);
17
+
font-size: var(--font-size-sm);
18
+
}
19
+
`;
20
+
21
+
async connectedCallback() {
22
+
super.connectedCallback();
23
+
const params = new URLSearchParams(window.location.search);
24
+
if (params.get('start') === '1') {
25
+
window.history.replaceState({}, '', '/oauth/callback');
26
+
await auth.startOAuthFromCallback();
27
+
}
28
+
}
29
+
30
+
render() {
31
+
return html`
32
+
<grain-spinner size="32"></grain-spinner>
33
+
<p>Signing in...</p>
34
+
`;
35
+
}
36
+
}
37
+
38
+
customElements.define('grain-oauth-callback', GrainOAuthCallback);
+391
src/components/pages/grain-onboarding.js
+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);
+11
-4
src/components/pages/grain-settings.js
+11
-4
src/components/pages/grain-settings.js
···
91
91
92
92
connectedCallback() {
93
93
super.connectedCallback();
94
+
95
+
// Redirect to timeline if not authenticated
96
+
if (!auth.isAuthenticated) {
97
+
router.replace('/');
98
+
return;
99
+
}
100
+
94
101
this._canInstall = pwa.canInstall;
95
102
this._showIOSInstructions = pwa.showIOSInstructions;
96
103
this.#unsubscribe = pwa.subscribe((canInstall) => {
···
116
123
}
117
124
118
125
#signOut() {
119
-
auth.logout();
120
126
router.push('/');
127
+
auth.logout();
121
128
}
122
129
123
130
#goToTerms() {
124
-
router.push('/settings/terms');
131
+
router.push('/legal/terms');
125
132
}
126
133
127
134
#goToPrivacy() {
128
-
router.push('/settings/privacy');
135
+
router.push('/legal/privacy');
129
136
}
130
137
131
138
#goToCopyright() {
132
-
router.push('/settings/copyright');
139
+
router.push('/legal/copyright');
133
140
}
134
141
135
142
render() {
+128
-1
src/components/pages/grain-timeline.js
+128
-1
src/components/pages/grain-timeline.js
···
8
8
import '../organisms/grain-comment-sheet.js';
9
9
import '../molecules/grain-pull-to-refresh.js';
10
10
import '../atoms/grain-spinner.js';
11
+
import '../atoms/grain-scroll-to-top.js';
11
12
12
13
export class GrainTimeline extends LitElement {
13
14
static properties = {
···
20
21
_commentSheetOpen: { state: true },
21
22
_commentGalleryUri: { state: true },
22
23
_focusPhotoUri: { state: true },
23
-
_focusPhotoUrl: { state: true }
24
+
_focusPhotoUrl: { state: true },
25
+
_showScrollTop: { state: true },
26
+
_pendingGallery: { state: true }
24
27
};
25
28
26
29
static styles = css`
···
44
47
45
48
#observer = null;
46
49
#initialized = false;
50
+
#boundHandleScroll = null;
51
+
#scrollContainer = null;
47
52
48
53
constructor() {
49
54
super();
···
57
62
this._commentGalleryUri = '';
58
63
this._focusPhotoUri = null;
59
64
this._focusPhotoUrl = null;
65
+
this._showScrollTop = false;
66
+
this._pendingGallery = null;
60
67
61
68
// Check cache synchronously to avoid flash
62
69
this.#initFromCache();
···
86
93
if (!this.#initialized) {
87
94
this.#fetchTimeline();
88
95
}
96
+
this.#boundHandleScroll = this.#handleScroll.bind(this);
97
+
this.#scrollContainer = this.#findScrollContainer();
98
+
(this.#scrollContainer || window).addEventListener('scroll', this.#boundHandleScroll, { passive: true });
99
+
document.addEventListener('dialog-action', this.#handleDialogAction);
100
+
}
101
+
102
+
#findScrollContainer() {
103
+
let el = this;
104
+
while (el) {
105
+
const parent = el.parentElement || el.getRootNode()?.host;
106
+
if (!parent || parent === document.documentElement) break;
107
+
108
+
const style = getComputedStyle(parent);
109
+
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
110
+
return parent;
111
+
}
112
+
el = parent;
113
+
}
114
+
return null;
89
115
}
90
116
91
117
disconnectedCallback() {
92
118
super.disconnectedCallback();
93
119
this.#observer?.disconnect();
120
+
if (this.#boundHandleScroll) {
121
+
(this.#scrollContainer || window).removeEventListener('scroll', this.#boundHandleScroll);
122
+
}
123
+
document.removeEventListener('dialog-action', this.#handleDialogAction);
94
124
}
95
125
96
126
firstUpdated() {
···
193
223
this._focusPhotoUrl = null;
194
224
}
195
225
226
+
#handleGalleryMenu(e) {
227
+
const { gallery, isOwner } = e.detail;
228
+
this._pendingGallery = gallery;
229
+
230
+
this.dispatchEvent(new CustomEvent('open-dialog', {
231
+
bubbles: true,
232
+
composed: true,
233
+
detail: {
234
+
type: 'action',
235
+
props: {
236
+
actions: isOwner
237
+
? [{ label: 'Delete', action: 'delete', danger: true }]
238
+
: [{ label: 'Report gallery', action: 'report' }]
239
+
}
240
+
}
241
+
}));
242
+
}
243
+
244
+
#handleDialogAction = (e) => {
245
+
if (e.detail.action === 'delete') {
246
+
this.#handleDelete();
247
+
} else if (e.detail.action === 'report') {
248
+
this.dispatchEvent(new CustomEvent('open-dialog', {
249
+
bubbles: true,
250
+
composed: true,
251
+
detail: {
252
+
type: 'report',
253
+
props: { galleryUri: this._pendingGallery?.uri }
254
+
}
255
+
}));
256
+
}
257
+
};
258
+
259
+
async #handleDelete() {
260
+
if (!this._pendingGallery) return;
261
+
262
+
// Show loading state
263
+
this.dispatchEvent(new CustomEvent('open-dialog', {
264
+
bubbles: true,
265
+
composed: true,
266
+
detail: {
267
+
type: 'action',
268
+
props: {
269
+
actions: [{ label: 'Delete', action: 'delete', danger: true }],
270
+
loading: true,
271
+
loadingText: 'Deleting...'
272
+
}
273
+
}
274
+
}));
275
+
276
+
try {
277
+
const client = auth.getClient();
278
+
const rkey = this._pendingGallery.uri.split('/').pop();
279
+
280
+
await client.mutate(`
281
+
mutation DeleteGallery($rkey: String!) {
282
+
deleteSocialGrainGallery(rkey: $rkey) { uri }
283
+
}
284
+
`, { rkey });
285
+
286
+
this._galleries = this._galleries.filter(g => g.uri !== this._pendingGallery.uri);
287
+
this._pendingGallery = null;
288
+
289
+
// Close dialog by dispatching close (app listens)
290
+
this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true }));
291
+
} catch (err) {
292
+
console.error('Failed to delete gallery:', err);
293
+
this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true }));
294
+
}
295
+
}
296
+
297
+
#handleScroll() {
298
+
const scrollTop = this.#scrollContainer ? this.#scrollContainer.scrollTop : window.scrollY;
299
+
this._showScrollTop = scrollTop > 150;
300
+
}
301
+
302
+
async #handleScrollTop() {
303
+
if (this._refreshing) return;
304
+
305
+
if (this.#scrollContainer) {
306
+
this.#scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
307
+
} else {
308
+
window.scrollTo({ top: 0, behavior: 'smooth' });
309
+
}
310
+
311
+
// Wait for scroll to complete before refreshing
312
+
await new Promise(resolve => setTimeout(resolve, 400));
313
+
314
+
await this.#handleRefresh();
315
+
}
316
+
196
317
render() {
197
318
return html`
198
319
<grain-feed-layout>
···
200
321
?refreshing=${this._refreshing}
201
322
@refresh=${this.#handleRefresh}
202
323
@comment-click=${this.#handleCommentClick}
324
+
@open-gallery-menu=${this.#handleGalleryMenu}
203
325
>
204
326
${this._error ? html`
205
327
<p class="error">${this._error}</p>
···
225
347
focusPhotoUrl=${this._focusPhotoUrl || ''}
226
348
@close=${this.#handleCommentSheetClose}
227
349
></grain-comment-sheet>
350
+
351
+
<grain-scroll-to-top
352
+
?visible=${this._showScrollTop}
353
+
@scroll-top=${this.#handleScrollTop}
354
+
></grain-scroll-to-top>
228
355
</grain-feed-layout>
229
356
`;
230
357
}
+2
-7
src/router.js
+2
-7
src/router.js
···
20
20
21
21
connect(outlet) {
22
22
this.#outlet = outlet;
23
-
window.addEventListener('popstate', () => {
24
-
if (document.startViewTransition) {
25
-
document.startViewTransition(() => this.#navigate());
26
-
} else {
27
-
this.#navigate();
28
-
}
29
-
});
23
+
// Skip View Transitions for popstate - browser gestures provide their own
24
+
window.addEventListener('popstate', () => this.#navigate());
30
25
this.#navigate();
31
26
return this;
32
27
}
+29
-1
src/services/auth.js
+29
-1
src/services/auth.js
···
1
1
import { createQuicksliceClient } from 'quickslice-client-js';
2
+
import { router } from '../router.js';
3
+
import { grainApi } from './grain-api.js';
2
4
3
5
class AuthService {
4
6
#client = null;
···
18
20
// Handle OAuth callback if present
19
21
if (window.location.search.includes('code=')) {
20
22
await this.#client.handleRedirectCallback();
21
-
window.history.replaceState({}, '', window.location.pathname);
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
34
+
const returnUrl = sessionStorage.getItem('oauth_return_url') || '/';
35
+
sessionStorage.removeItem('oauth_return_url');
36
+
window.location.replace(returnUrl);
37
+
return;
22
38
}
23
39
24
40
// Load user if authenticated
···
56
72
}
57
73
58
74
async login(handle) {
75
+
sessionStorage.setItem('oauth_return_url', window.location.pathname);
76
+
sessionStorage.setItem('oauth_handle', handle);
77
+
window.location.href = '/oauth/callback?start=1';
78
+
}
79
+
80
+
async startOAuthFromCallback() {
81
+
const handle = sessionStorage.getItem('oauth_handle');
82
+
sessionStorage.removeItem('oauth_handle');
83
+
if (!handle) {
84
+
router.replace('/');
85
+
return;
86
+
}
59
87
await this.#client.loginWithRedirect({ handle });
60
88
}
61
89
+8
-1
src/services/draft-gallery.js
+8
-1
src/services/draft-gallery.js
···
2
2
#photos = [];
3
3
4
4
setPhotos(photos) {
5
-
this.#photos = [...photos];
5
+
// Ensure each photo has an alt property
6
+
this.#photos = photos.map(p => ({ ...p, alt: p.alt || '' }));
6
7
}
7
8
8
9
getPhotos() {
9
10
return this.#photos;
11
+
}
12
+
13
+
updatePhotoAlt(index, alt) {
14
+
if (index >= 0 && index < this.#photos.length) {
15
+
this.#photos[index] = { ...this.#photos[index], alt };
16
+
}
10
17
}
11
18
12
19
clear() {
+47
src/services/grain-api.js
+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();
+35
src/services/mutations.js
+35
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
+
}
221
+
222
+
async createReport(subjectUri, reasonType, reason = null) {
223
+
const client = auth.getClient();
224
+
const result = await client.mutate(`
225
+
mutation CreateReport($subjectUri: String!, $reasonType: ReportReasonType!, $reason: String) {
226
+
createReport(subjectUri: $subjectUri, reasonType: $reasonType, reason: $reason) {
227
+
id
228
+
status
229
+
createdAt
230
+
}
231
+
}
232
+
`, { subjectUri, reasonType, reason });
233
+
234
+
return result.createReport;
235
+
}
201
236
}
202
237
203
238
export const mutations = new MutationsService();
+49
src/utils/haptics.js
+49
src/utils/haptics.js
···
1
+
/**
2
+
* Haptic feedback utility for PWA
3
+
* - iOS 18+: Uses checkbox switch element hack
4
+
* - Android: Uses Vibration API
5
+
* - Other: Silently does nothing
6
+
*/
7
+
8
+
// Platform detection
9
+
const isIOS = /iPhone|iPad/.test(navigator.userAgent);
10
+
const hasVibrate = 'vibrate' in navigator;
11
+
12
+
// Lazy-initialized hidden elements for iOS
13
+
let checkbox = null;
14
+
let label = null;
15
+
16
+
function ensureElements() {
17
+
if (checkbox) return;
18
+
19
+
checkbox = document.createElement('input');
20
+
checkbox.type = 'checkbox';
21
+
checkbox.setAttribute('switch', '');
22
+
checkbox.id = 'haptic-trigger';
23
+
checkbox.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;';
24
+
25
+
label = document.createElement('label');
26
+
label.htmlFor = 'haptic-trigger';
27
+
label.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none;';
28
+
29
+
document.body.append(checkbox, label);
30
+
}
31
+
32
+
/**
33
+
* Trigger a light haptic tap
34
+
*/
35
+
export function trigger() {
36
+
if (isIOS) {
37
+
ensureElements();
38
+
label.click();
39
+
} else if (hasVibrate) {
40
+
navigator.vibrate(10);
41
+
}
42
+
}
43
+
44
+
/**
45
+
* Check if haptics are supported on this device
46
+
*/
47
+
export function isSupported() {
48
+
return isIOS || hasVibrate;
49
+
}