.DS_Store
.DS_Store
This is a binary file and will not be displayed.
+201
LICENSE
+201
LICENSE
···
1
+
Apache License
2
+
Version 2.0, January 2004
3
+
http://www.apache.org/licenses/
4
+
5
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+
1. Definitions.
8
+
9
+
"License" shall mean the terms and conditions for use, reproduction,
10
+
and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+
"Licensor" shall mean the copyright owner or entity authorized by
13
+
the copyright owner that is granting the License.
14
+
15
+
"Legal Entity" shall mean the union of the acting entity and all
16
+
other entities that control, are controlled by, or are under common
17
+
control with that entity. For the purposes of this definition,
18
+
"control" means (i) the power, direct or indirect, to cause the
19
+
direction or management of such entity, whether by contract or
20
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+
outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+
"You" (or "Your") shall mean an individual or Legal Entity
24
+
exercising permissions granted by this License.
25
+
26
+
"Source" form shall mean the preferred form for making modifications,
27
+
including but not limited to software source code, documentation
28
+
source, and configuration files.
29
+
30
+
"Object" form shall mean any form resulting from mechanical
31
+
transformation or translation of a Source form, including but
32
+
not limited to compiled object code, generated documentation,
33
+
and conversions to other media types.
34
+
35
+
"Work" shall mean the work of authorship, whether in Source or
36
+
Object form, made available under the License, as indicated by a
37
+
copyright notice that is included in or attached to the work
38
+
(an example is provided in the Appendix below).
39
+
40
+
"Derivative Works" shall mean any work, whether in Source or Object
41
+
form, that is based on (or derived from) the Work and for which the
42
+
editorial revisions, annotations, elaborations, or other modifications
43
+
represent, as a whole, an original work of authorship. For the purposes
44
+
of this License, Derivative Works shall not include works that remain
45
+
separable from, or merely link (or bind by name) to the interfaces of,
46
+
the Work and Derivative Works thereof.
47
+
48
+
"Contribution" shall mean any work of authorship, including
49
+
the original version of the Work and any modifications or additions
50
+
to that Work or Derivative Works thereof, that is intentionally
51
+
submitted to Licensor for inclusion in the Work by the copyright owner
52
+
or by an individual or Legal Entity authorized to submit on behalf of
53
+
the copyright owner. For the purposes of this definition, "submitted"
54
+
means any form of electronic, verbal, or written communication sent
55
+
to the Licensor or its representatives, including but not limited to
56
+
communication on electronic mailing lists, source code control systems,
57
+
and issue tracking systems that are managed by, or on behalf of, the
58
+
Licensor for the purpose of discussing and improving the Work, but
59
+
excluding communication that is conspicuously marked or otherwise
60
+
designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+
"Contributor" shall mean Licensor and any individual or Legal Entity
63
+
on behalf of whom a Contribution has been received by Licensor and
64
+
subsequently incorporated within the Work.
65
+
66
+
2. Grant of Copyright License. Subject to the terms and conditions of
67
+
this License, each Contributor hereby grants to You a perpetual,
68
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+
copyright license to reproduce, prepare Derivative Works of,
70
+
publicly display, publicly perform, sublicense, and distribute the
71
+
Work and such Derivative Works in Source or Object form.
72
+
73
+
3. Grant of Patent License. Subject to the terms and conditions of
74
+
this License, each Contributor hereby grants to You a perpetual,
75
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+
(except as stated in this section) patent license to make, have made,
77
+
use, offer to sell, sell, import, and otherwise transfer the Work,
78
+
where such license applies only to those patent claims licensable
79
+
by such Contributor that are necessarily infringed by their
80
+
Contribution(s) alone or by combination of their Contribution(s)
81
+
with the Work to which such Contribution(s) was submitted. If You
82
+
institute patent litigation against any entity (including a
83
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+
or a Contribution incorporated within the Work constitutes direct
85
+
or contributory patent infringement, then any patent licenses
86
+
granted to You under this License for that Work shall terminate
87
+
as of the date such litigation is filed.
88
+
89
+
4. Redistribution. You may reproduce and distribute copies of the
90
+
Work or Derivative Works thereof in any medium, with or without
91
+
modifications, and in Source or Object form, provided that You
92
+
meet the following conditions:
93
+
94
+
(a) You must give any other recipients of the Work or
95
+
Derivative Works a copy of this License; and
96
+
97
+
(b) You must cause any modified files to carry prominent notices
98
+
stating that You changed the files; and
99
+
100
+
(c) You must retain, in the Source form of any Derivative Works
101
+
that You distribute, all copyright, patent, trademark, and
102
+
attribution notices from the Source form of the Work,
103
+
excluding those notices that do not pertain to any part of
104
+
the Derivative Works; and
105
+
106
+
(d) If the Work includes a "NOTICE" text file as part of its
107
+
distribution, then any Derivative Works that You distribute must
108
+
include a readable copy of the attribution notices contained
109
+
within such NOTICE file, excluding those notices that do not
110
+
pertain to any part of the Derivative Works, in at least one
111
+
of the following places: within a NOTICE text file distributed
112
+
as part of the Derivative Works; within the Source form or
113
+
documentation, if provided along with the Derivative Works; or,
114
+
within a display generated by the Derivative Works, if and
115
+
wherever such third-party notices normally appear. The contents
116
+
of the NOTICE file are for informational purposes only and
117
+
do not modify the License. You may add Your own attribution
118
+
notices within Derivative Works that You distribute, alongside
119
+
or as an addendum to the NOTICE text from the Work, provided
120
+
that such additional attribution notices cannot be construed
121
+
as modifying the License.
122
+
123
+
You may add Your own copyright statement to Your modifications and
124
+
may provide additional or different license terms and conditions
125
+
for use, reproduction, or distribution of Your modifications, or
126
+
for any such Derivative Works as a whole, provided Your use,
127
+
reproduction, and distribution of the Work otherwise complies with
128
+
the conditions stated in this License.
129
+
130
+
5. Submission of Contributions. Unless You explicitly state otherwise,
131
+
any Contribution intentionally submitted for inclusion in the Work
132
+
by You to the Licensor shall be under the terms and conditions of
133
+
this License, without any additional terms or conditions.
134
+
Notwithstanding the above, nothing herein shall supersede or modify
135
+
the terms of any separate license agreement you may have executed
136
+
with Licensor regarding such Contributions.
137
+
138
+
6. Trademarks. This License does not grant permission to use the trade
139
+
names, trademarks, service marks, or product names of the Licensor,
140
+
except as required for reasonable and customary use in describing the
141
+
origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+
7. Disclaimer of Warranty. Unless required by applicable law or
144
+
agreed to in writing, Licensor provides the Work (and each
145
+
Contributor provides its Contributions) on an "AS IS" BASIS,
146
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+
implied, including, without limitation, any warranties or conditions
148
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+
PARTICULAR PURPOSE. You are solely responsible for determining the
150
+
appropriateness of using or redistributing the Work and assume any
151
+
risks associated with Your exercise of permissions under this License.
152
+
153
+
8. Limitation of Liability. In no event and under no legal theory,
154
+
whether in tort (including negligence), contract, or otherwise,
155
+
unless required by applicable law (such as deliberate and grossly
156
+
negligent acts) or agreed to in writing, shall any Contributor be
157
+
liable to You for damages, including any direct, indirect, special,
158
+
incidental, or consequential damages of any character arising as a
159
+
result of this License or out of the use or inability to use the
160
+
Work (including but not limited to damages for loss of goodwill,
161
+
work stoppage, computer failure or malfunction, or any and all
162
+
other commercial damages or losses), even if such Contributor
163
+
has been advised of the possibility of such damages.
164
+
165
+
9. Accepting Warranty or Additional Liability. While redistributing
166
+
the Work or Derivative Works thereof, You may choose to offer,
167
+
and charge a fee for, acceptance of support, warranty, indemnity,
168
+
or other liability obligations and/or rights consistent with this
169
+
License. However, in accepting such obligations, You may act only
170
+
on Your own behalf and on Your sole responsibility, not on behalf
171
+
of any other Contributor, and only if You agree to indemnify,
172
+
defend, and hold each Contributor harmless for any liability
173
+
incurred by, or claims asserted against, such Contributor by reason
174
+
of your accepting any such warranty or additional liability.
175
+
176
+
END OF TERMS AND CONDITIONS
177
+
178
+
APPENDIX: How to apply the Apache License to your work.
179
+
180
+
To apply the Apache License to your work, attach the following
181
+
boilerplate notice, with the fields enclosed by brackets "[]"
182
+
replaced with your own identifying information. (Don't include
183
+
the brackets!) The text should be enclosed in the appropriate
184
+
comment syntax for the file format. We also recommend that a
185
+
file or class name and description of purpose be included on the
186
+
same "printed page" as the copyright notice for easier
187
+
identification within third-party archives.
188
+
189
+
Copyright [yyyy] [name of copyright owner]
190
+
191
+
Licensed under the Apache License, Version 2.0 (the "License");
192
+
you may not use this file except in compliance with the License.
193
+
You may obtain a copy of the License at
194
+
195
+
http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+
Unless required by applicable law or agreed to in writing, software
198
+
distributed under the License is distributed on an "AS IS" BASIS,
199
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+
See the License for the specific language governing permissions and
201
+
limitations under the License.
+4
README.md
+4
README.md
+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.
+191
docs/plans/2025-12-29-profile-skeleton-implementation.md
+191
docs/plans/2025-12-29-profile-skeleton-implementation.md
···
1
+
# Profile Header Skeleton 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 skeleton loader for the profile header to eliminate layout shift on fresh profile loads.
6
+
7
+
**Architecture:** Create a new Lit component that mirrors the exact layout dimensions of `grain-profile-header`, using animated placeholder shapes. Swap the spinner for this skeleton during loading state.
8
+
9
+
**Tech Stack:** Lit 3.x, CSS custom properties, CSS animations
10
+
11
+
---
12
+
13
+
### Task 1: Create the Skeleton Component
14
+
15
+
**Files:**
16
+
- Create: `src/components/molecules/grain-profile-header-skeleton.js`
17
+
18
+
**Step 1: Create the skeleton component file**
19
+
20
+
Create `src/components/molecules/grain-profile-header-skeleton.js`:
21
+
22
+
```javascript
23
+
import { LitElement, html, css } from 'lit';
24
+
25
+
export class GrainProfileHeaderSkeleton extends LitElement {
26
+
static styles = css`
27
+
:host {
28
+
display: block;
29
+
padding: var(--space-md) var(--space-sm);
30
+
}
31
+
@media (min-width: 600px) {
32
+
:host {
33
+
padding-left: 0;
34
+
padding-right: 0;
35
+
}
36
+
}
37
+
.top-row {
38
+
display: flex;
39
+
align-items: flex-start;
40
+
gap: var(--space-md);
41
+
margin-bottom: var(--space-sm);
42
+
}
43
+
.right-column {
44
+
flex: 1;
45
+
min-width: 0;
46
+
padding-top: var(--space-xs);
47
+
}
48
+
.placeholder {
49
+
background: var(--color-bg-elevated);
50
+
border-radius: 4px;
51
+
animation: pulse 1.5s ease-in-out infinite;
52
+
}
53
+
.avatar {
54
+
width: var(--avatar-size-lg, 80px);
55
+
height: var(--avatar-size-lg, 80px);
56
+
border-radius: 50%;
57
+
flex-shrink: 0;
58
+
}
59
+
.handle {
60
+
width: 120px;
61
+
height: 20px;
62
+
margin-bottom: var(--space-xs);
63
+
}
64
+
.name {
65
+
width: 80px;
66
+
height: 14px;
67
+
margin-bottom: var(--space-xs);
68
+
}
69
+
.stats {
70
+
display: flex;
71
+
gap: var(--space-sm);
72
+
margin-bottom: var(--space-xs);
73
+
}
74
+
.stat {
75
+
width: 70px;
76
+
height: 14px;
77
+
}
78
+
.bio-line {
79
+
height: 14px;
80
+
margin-top: var(--space-xs);
81
+
}
82
+
.bio-line:first-of-type {
83
+
width: 100%;
84
+
}
85
+
.bio-line:last-of-type {
86
+
width: 60%;
87
+
}
88
+
.button {
89
+
width: 100%;
90
+
height: 40px;
91
+
border-radius: 8px;
92
+
margin-top: var(--space-sm);
93
+
}
94
+
@keyframes pulse {
95
+
0%, 100% { opacity: 0.4; }
96
+
50% { opacity: 1; }
97
+
}
98
+
`;
99
+
100
+
render() {
101
+
return html`
102
+
<div class="top-row">
103
+
<div class="placeholder avatar"></div>
104
+
<div class="right-column">
105
+
<div class="placeholder handle"></div>
106
+
<div class="placeholder name"></div>
107
+
<div class="stats">
108
+
<div class="placeholder stat"></div>
109
+
<div class="placeholder stat"></div>
110
+
<div class="placeholder stat"></div>
111
+
</div>
112
+
<div class="placeholder bio-line"></div>
113
+
<div class="placeholder bio-line"></div>
114
+
</div>
115
+
</div>
116
+
<div class="placeholder button"></div>
117
+
`;
118
+
}
119
+
}
120
+
121
+
customElements.define('grain-profile-header-skeleton', GrainProfileHeaderSkeleton);
122
+
```
123
+
124
+
**Step 2: Commit the new component**
125
+
126
+
```bash
127
+
git add src/components/molecules/grain-profile-header-skeleton.js
128
+
git commit -m "feat: add profile header skeleton component"
129
+
```
130
+
131
+
---
132
+
133
+
### Task 2: Integrate Skeleton into Profile Page
134
+
135
+
**Files:**
136
+
- Modify: `src/components/pages/grain-profile.js:4` (add import)
137
+
- Modify: `src/components/pages/grain-profile.js:171` (swap spinner for skeleton)
138
+
139
+
**Step 1: Add import for skeleton component**
140
+
141
+
In `src/components/pages/grain-profile.js`, add import after line 8 (after `grain-avatar-crop.js`):
142
+
143
+
```javascript
144
+
import '../molecules/grain-profile-header-skeleton.js';
145
+
```
146
+
147
+
**Step 2: Replace spinner with skeleton**
148
+
149
+
In `src/components/pages/grain-profile.js`, change line 171 from:
150
+
151
+
```javascript
152
+
${this._loading ? html`<grain-spinner></grain-spinner>` : ''}
153
+
```
154
+
155
+
To:
156
+
157
+
```javascript
158
+
${this._loading ? html`<grain-profile-header-skeleton></grain-profile-header-skeleton>` : ''}
159
+
```
160
+
161
+
**Step 3: Commit the integration**
162
+
163
+
```bash
164
+
git add src/components/pages/grain-profile.js
165
+
git commit -m "feat: use skeleton loader for profile page"
166
+
```
167
+
168
+
---
169
+
170
+
### Task 3: Manual Verification
171
+
172
+
**Step 1: Test fresh profile load**
173
+
174
+
1. Run the dev server: `npm run dev`
175
+
2. Clear browser cache or open incognito
176
+
3. Navigate to a profile page (e.g., `/profile/somehandle`)
177
+
4. Observe: skeleton should appear with pulsing animation
178
+
5. Verify: no layout shift when profile data loads
179
+
180
+
**Step 2: Test cached navigation**
181
+
182
+
1. Navigate away from profile
183
+
2. Navigate back to same profile
184
+
3. Verify: if cached, profile loads instantly (no skeleton)
185
+
4. Verify: if not cached, skeleton appears
186
+
187
+
**Step 3: Verify responsive layout**
188
+
189
+
1. Test on mobile viewport (< 600px)
190
+
2. Test on desktop viewport (> 600px)
191
+
3. Verify: skeleton matches header padding in both views
+922
docs/plans/2025-12-29-richtext-facets.md
+922
docs/plans/2025-12-29-richtext-facets.md
···
1
+
# Rich Text Facets Implementation Plan
2
+
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+
**Goal:** Add Bluesky-compatible rich text facets (mentions, links, hashtags) to comments, gallery descriptions, and profile descriptions.
6
+
7
+
**Architecture:** Adapt `~/code/tools/richtext.js` for Bluesky facet types. Parse facets on save for comments/galleries, parse on render for profiles. Create `<grain-rich-text>` component for display.
8
+
9
+
**Tech Stack:** Lit components, Bluesky `app.bsky.richtext.facet` format, TextEncoder for UTF-8 byte positions.
10
+
11
+
---
12
+
13
+
## Task 1: Create richtext.js Library
14
+
15
+
**Files:**
16
+
- Create: `src/lib/richtext.js`
17
+
18
+
**Step 1: Create the richtext library with Bluesky facet parsing**
19
+
20
+
```javascript
21
+
// src/lib/richtext.js - Bluesky-compatible richtext parsing and rendering
22
+
23
+
/**
24
+
* Parse text for Bluesky facets: mentions, links, hashtags.
25
+
* Returns { text, facets } with byte-indexed positions.
26
+
*
27
+
* @param {string} text - Plain text to parse
28
+
* @param {function} resolveHandle - Optional async function to resolve @handle to DID
29
+
* @returns {Promise<{ text: string, facets: Array }>}
30
+
*/
31
+
export async function parseTextToFacets(text, resolveHandle = null) {
32
+
if (!text) return { text: '', facets: [] };
33
+
34
+
const facets = [];
35
+
const encoder = new TextEncoder();
36
+
37
+
function getByteOffset(str, charIndex) {
38
+
return encoder.encode(str.slice(0, charIndex)).length;
39
+
}
40
+
41
+
// Track claimed positions to avoid overlaps
42
+
const claimedPositions = new Set();
43
+
44
+
function isRangeClaimed(start, end) {
45
+
for (let i = start; i < end; i++) {
46
+
if (claimedPositions.has(i)) return true;
47
+
}
48
+
return false;
49
+
}
50
+
51
+
function claimRange(start, end) {
52
+
for (let i = start; i < end; i++) {
53
+
claimedPositions.add(i);
54
+
}
55
+
}
56
+
57
+
// URLs first (highest priority)
58
+
const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g;
59
+
let urlMatch;
60
+
while ((urlMatch = urlRegex.exec(text)) !== null) {
61
+
const start = urlMatch.index;
62
+
const end = start + urlMatch[0].length;
63
+
64
+
if (!isRangeClaimed(start, end)) {
65
+
claimRange(start, end);
66
+
facets.push({
67
+
index: {
68
+
byteStart: getByteOffset(text, start),
69
+
byteEnd: getByteOffset(text, end),
70
+
},
71
+
features: [{
72
+
$type: 'app.bsky.richtext.facet#link',
73
+
uri: urlMatch[0],
74
+
}],
75
+
});
76
+
}
77
+
}
78
+
79
+
// Mentions: @handle or @handle.domain.tld
80
+
const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g;
81
+
let mentionMatch;
82
+
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
83
+
const start = mentionMatch.index;
84
+
const end = start + mentionMatch[0].length;
85
+
const handle = mentionMatch[0].slice(1); // Remove @
86
+
87
+
if (!isRangeClaimed(start, end)) {
88
+
// Try to resolve handle to DID
89
+
let did = null;
90
+
if (resolveHandle) {
91
+
try {
92
+
did = await resolveHandle(handle);
93
+
} catch (e) {
94
+
// Skip this mention if resolution fails
95
+
continue;
96
+
}
97
+
}
98
+
99
+
if (did) {
100
+
claimRange(start, end);
101
+
facets.push({
102
+
index: {
103
+
byteStart: getByteOffset(text, start),
104
+
byteEnd: getByteOffset(text, end),
105
+
},
106
+
features: [{
107
+
$type: 'app.bsky.richtext.facet#mention',
108
+
did,
109
+
}],
110
+
});
111
+
}
112
+
}
113
+
}
114
+
115
+
// Hashtags: #tag (alphanumeric, no leading numbers)
116
+
const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g;
117
+
let hashtagMatch;
118
+
while ((hashtagMatch = hashtagRegex.exec(text)) !== null) {
119
+
const start = hashtagMatch.index;
120
+
const end = start + hashtagMatch[0].length;
121
+
const tag = hashtagMatch[1]; // Without #
122
+
123
+
if (!isRangeClaimed(start, end)) {
124
+
claimRange(start, end);
125
+
facets.push({
126
+
index: {
127
+
byteStart: getByteOffset(text, start),
128
+
byteEnd: getByteOffset(text, end),
129
+
},
130
+
features: [{
131
+
$type: 'app.bsky.richtext.facet#tag',
132
+
tag,
133
+
}],
134
+
});
135
+
}
136
+
}
137
+
138
+
// Sort by byte position
139
+
facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
140
+
141
+
return { text, facets };
142
+
}
143
+
144
+
/**
145
+
* Synchronous parsing for client-side render (no DID resolution).
146
+
* Mentions display as-is without profile links.
147
+
*/
148
+
export function parseTextToFacetsSync(text) {
149
+
if (!text) return { text: '', facets: [] };
150
+
151
+
const facets = [];
152
+
const encoder = new TextEncoder();
153
+
154
+
function getByteOffset(str, charIndex) {
155
+
return encoder.encode(str.slice(0, charIndex)).length;
156
+
}
157
+
158
+
const claimedPositions = new Set();
159
+
160
+
function isRangeClaimed(start, end) {
161
+
for (let i = start; i < end; i++) {
162
+
if (claimedPositions.has(i)) return true;
163
+
}
164
+
return false;
165
+
}
166
+
167
+
function claimRange(start, end) {
168
+
for (let i = start; i < end; i++) {
169
+
claimedPositions.add(i);
170
+
}
171
+
}
172
+
173
+
// URLs
174
+
const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g;
175
+
let urlMatch;
176
+
while ((urlMatch = urlRegex.exec(text)) !== null) {
177
+
const start = urlMatch.index;
178
+
const end = start + urlMatch[0].length;
179
+
180
+
if (!isRangeClaimed(start, end)) {
181
+
claimRange(start, end);
182
+
facets.push({
183
+
index: {
184
+
byteStart: getByteOffset(text, start),
185
+
byteEnd: getByteOffset(text, end),
186
+
},
187
+
features: [{
188
+
$type: 'app.bsky.richtext.facet#link',
189
+
uri: urlMatch[0],
190
+
}],
191
+
});
192
+
}
193
+
}
194
+
195
+
// Hashtags
196
+
const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g;
197
+
let hashtagMatch;
198
+
while ((hashtagMatch = hashtagRegex.exec(text)) !== null) {
199
+
const start = hashtagMatch.index;
200
+
const end = start + hashtagMatch[0].length;
201
+
const tag = hashtagMatch[1];
202
+
203
+
if (!isRangeClaimed(start, end)) {
204
+
claimRange(start, end);
205
+
facets.push({
206
+
index: {
207
+
byteStart: getByteOffset(text, start),
208
+
byteEnd: getByteOffset(text, end),
209
+
},
210
+
features: [{
211
+
$type: 'app.bsky.richtext.facet#tag',
212
+
tag,
213
+
}],
214
+
});
215
+
}
216
+
}
217
+
218
+
facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
219
+
return { text, facets };
220
+
}
221
+
222
+
/**
223
+
* Render text with facets as HTML.
224
+
*
225
+
* @param {string} text - The text content
226
+
* @param {Array} facets - Array of facet objects
227
+
* @param {Object} options - Rendering options
228
+
* @returns {string} HTML string
229
+
*/
230
+
export function renderFacetedText(text, facets, options = {}) {
231
+
if (!text) return '';
232
+
233
+
// If no facets, just escape and return
234
+
if (!facets || facets.length === 0) {
235
+
return escapeHtml(text);
236
+
}
237
+
238
+
const encoder = new TextEncoder();
239
+
const decoder = new TextDecoder();
240
+
const bytes = encoder.encode(text);
241
+
242
+
// Sort facets by start position
243
+
const sortedFacets = [...facets].sort(
244
+
(a, b) => a.index.byteStart - b.index.byteStart
245
+
);
246
+
247
+
let result = '';
248
+
let lastEnd = 0;
249
+
250
+
for (const facet of sortedFacets) {
251
+
// Validate byte indices
252
+
if (facet.index.byteStart < 0 || facet.index.byteEnd > bytes.length) {
253
+
continue; // Skip invalid facets
254
+
}
255
+
256
+
// Add text before this facet
257
+
if (facet.index.byteStart > lastEnd) {
258
+
const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart);
259
+
result += escapeHtml(decoder.decode(beforeBytes));
260
+
}
261
+
262
+
// Get the faceted text
263
+
const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd);
264
+
const facetText = decoder.decode(facetBytes);
265
+
266
+
// Determine facet type and render
267
+
const feature = facet.features?.[0];
268
+
if (!feature) {
269
+
result += escapeHtml(facetText);
270
+
lastEnd = facet.index.byteEnd;
271
+
continue;
272
+
}
273
+
274
+
const type = feature.$type || feature.__typename || '';
275
+
276
+
if (type.includes('link')) {
277
+
const uri = feature.uri || '';
278
+
result += `<a href="${escapeHtml(uri)}" target="_blank" rel="noopener noreferrer" class="facet-link">${escapeHtml(facetText)}</a>`;
279
+
} else if (type.includes('mention')) {
280
+
// Extract handle from text (remove @)
281
+
const handle = facetText.startsWith('@') ? facetText.slice(1) : facetText;
282
+
result += `<a href="/profile/${escapeHtml(handle)}" class="facet-mention">${escapeHtml(facetText)}</a>`;
283
+
} else if (type.includes('tag')) {
284
+
// Hashtag - styled but not clickable for now
285
+
result += `<span class="facet-tag">${escapeHtml(facetText)}</span>`;
286
+
} else {
287
+
result += escapeHtml(facetText);
288
+
}
289
+
290
+
lastEnd = facet.index.byteEnd;
291
+
}
292
+
293
+
// Add remaining text
294
+
if (lastEnd < bytes.length) {
295
+
const remainingBytes = bytes.slice(lastEnd);
296
+
result += escapeHtml(decoder.decode(remainingBytes));
297
+
}
298
+
299
+
return result;
300
+
}
301
+
302
+
function escapeHtml(text) {
303
+
return text
304
+
.replace(/&/g, '&')
305
+
.replace(/</g, '<')
306
+
.replace(/>/g, '>')
307
+
.replace(/"/g, '"')
308
+
.replace(/'/g, ''');
309
+
}
310
+
```
311
+
312
+
**Step 2: Verify file exists**
313
+
314
+
Run: `ls -la src/lib/richtext.js`
315
+
Expected: File exists with correct permissions
316
+
317
+
**Step 3: Commit**
318
+
319
+
```bash
320
+
git add src/lib/richtext.js
321
+
git commit -m "feat: add richtext library for Bluesky facets"
322
+
```
323
+
324
+
---
325
+
326
+
## Task 2: Add Handle Resolution to grain-api
327
+
328
+
**Files:**
329
+
- Modify: `src/services/grain-api.js`
330
+
331
+
**Step 1: Add resolveHandle method to GrainApiService**
332
+
333
+
Add after the `getComments` method (around line 1095):
334
+
335
+
```javascript
336
+
async resolveHandle(handle) {
337
+
const query = `
338
+
query ResolveHandle($handle: String!) {
339
+
socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) {
340
+
edges {
341
+
node { did }
342
+
}
343
+
}
344
+
}
345
+
`;
346
+
347
+
const response = await this.#execute(query, { handle });
348
+
const did = response.data?.socialGrainActorProfile?.edges?.[0]?.node?.did;
349
+
350
+
if (!did) {
351
+
throw new Error(`Handle not found: ${handle}`);
352
+
}
353
+
354
+
return did;
355
+
}
356
+
```
357
+
358
+
**Step 2: Verify syntax**
359
+
360
+
Run: `node --check src/services/grain-api.js`
361
+
Expected: No syntax errors
362
+
363
+
**Step 3: Commit**
364
+
365
+
```bash
366
+
git add src/services/grain-api.js
367
+
git commit -m "feat: add resolveHandle method for mention facets"
368
+
```
369
+
370
+
---
371
+
372
+
## Task 3: Create grain-rich-text Component
373
+
374
+
**Files:**
375
+
- Create: `src/components/atoms/grain-rich-text.js`
376
+
377
+
**Step 1: Create the component**
378
+
379
+
```javascript
380
+
// src/components/atoms/grain-rich-text.js
381
+
import { LitElement, html, css } from 'lit';
382
+
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
383
+
import { renderFacetedText, parseTextToFacetsSync } from '../../lib/richtext.js';
384
+
385
+
export class GrainRichText extends LitElement {
386
+
static properties = {
387
+
text: { type: String },
388
+
facets: { type: Array },
389
+
parse: { type: Boolean }
390
+
};
391
+
392
+
static styles = css`
393
+
:host {
394
+
display: inline;
395
+
}
396
+
.facet-link {
397
+
color: var(--color-link, #0066cc);
398
+
text-decoration: none;
399
+
}
400
+
.facet-link:hover {
401
+
text-decoration: underline;
402
+
}
403
+
.facet-mention {
404
+
color: var(--color-link, #0066cc);
405
+
text-decoration: none;
406
+
}
407
+
.facet-mention:hover {
408
+
text-decoration: underline;
409
+
}
410
+
.facet-tag {
411
+
color: var(--color-link, #0066cc);
412
+
}
413
+
`;
414
+
415
+
constructor() {
416
+
super();
417
+
this.text = '';
418
+
this.facets = null;
419
+
this.parse = false;
420
+
}
421
+
422
+
render() {
423
+
if (!this.text) return '';
424
+
425
+
let facetsToUse = this.facets;
426
+
427
+
// If parse mode and no facets provided, parse on the fly
428
+
if (this.parse && (!this.facets || this.facets.length === 0)) {
429
+
const parsed = parseTextToFacetsSync(this.text);
430
+
facetsToUse = parsed.facets;
431
+
}
432
+
433
+
const htmlContent = renderFacetedText(this.text, facetsToUse || []);
434
+
return html`${unsafeHTML(htmlContent)}`;
435
+
}
436
+
}
437
+
438
+
customElements.define('grain-rich-text', GrainRichText);
439
+
```
440
+
441
+
**Step 2: Verify syntax**
442
+
443
+
Run: `node --check src/components/atoms/grain-rich-text.js`
444
+
Expected: No syntax errors
445
+
446
+
**Step 3: Commit**
447
+
448
+
```bash
449
+
git add src/components/atoms/grain-rich-text.js
450
+
git commit -m "feat: add grain-rich-text component for facet rendering"
451
+
```
452
+
453
+
---
454
+
455
+
## Task 4: Integrate Facet Parsing into Comment Creation
456
+
457
+
**Files:**
458
+
- Modify: `src/services/mutations.js`
459
+
460
+
**Step 1: Add import at top of file**
461
+
462
+
```javascript
463
+
import { parseTextToFacets } from '../lib/richtext.js';
464
+
import { grainApi } from './grain-api.js';
465
+
```
466
+
467
+
**Step 2: Modify createComment method (around line 103)**
468
+
469
+
Replace the existing `createComment` method:
470
+
471
+
```javascript
472
+
async createComment(galleryUri, text, replyToUri = null, focusUri = null) {
473
+
const client = auth.getClient();
474
+
475
+
// Parse text for facets with handle resolution
476
+
const resolveHandle = async (handle) => grainApi.resolveHandle(handle);
477
+
const { facets } = await parseTextToFacets(text, resolveHandle);
478
+
479
+
const input = {
480
+
subject: galleryUri,
481
+
text,
482
+
createdAt: new Date().toISOString()
483
+
};
484
+
485
+
// Only include facets if we found any
486
+
if (facets && facets.length > 0) {
487
+
input.facets = facets;
488
+
}
489
+
490
+
if (replyToUri) {
491
+
input.replyTo = replyToUri;
492
+
}
493
+
494
+
if (focusUri) {
495
+
input.focus = focusUri;
496
+
}
497
+
498
+
const result = await client.mutate(`
499
+
mutation CreateComment($input: SocialGrainCommentInput!) {
500
+
createSocialGrainComment(input: $input) { uri }
501
+
}
502
+
`, { input });
503
+
504
+
return result.createSocialGrainComment.uri;
505
+
}
506
+
```
507
+
508
+
**Step 3: Verify syntax**
509
+
510
+
Run: `node --check src/services/mutations.js`
511
+
Expected: No syntax errors
512
+
513
+
**Step 4: Commit**
514
+
515
+
```bash
516
+
git add src/services/mutations.js
517
+
git commit -m "feat: parse facets when creating comments"
518
+
```
519
+
520
+
---
521
+
522
+
## Task 5: Integrate Facet Parsing into Gallery Creation
523
+
524
+
**Files:**
525
+
- Modify: `src/components/pages/grain-create-gallery.js`
526
+
527
+
**Step 1: Add import at top of file**
528
+
529
+
```javascript
530
+
import { parseTextToFacets } from '../../lib/richtext.js';
531
+
import { grainApi } from '../../services/grain-api.js';
532
+
```
533
+
534
+
**Step 2: Modify the gallery creation in #handlePost (around line 219)**
535
+
536
+
Find this code block:
537
+
```javascript
538
+
// Create gallery record
539
+
const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, {
540
+
input: {
541
+
title: this._title.trim(),
542
+
...(this._description.trim() && { description: this._description.trim() }),
543
+
createdAt: now
544
+
}
545
+
});
546
+
```
547
+
548
+
Replace with:
549
+
```javascript
550
+
// Parse description for facets
551
+
let facets = null;
552
+
if (this._description.trim()) {
553
+
const resolveHandle = async (handle) => grainApi.resolveHandle(handle);
554
+
const parsed = await parseTextToFacets(this._description.trim(), resolveHandle);
555
+
if (parsed.facets.length > 0) {
556
+
facets = parsed.facets;
557
+
}
558
+
}
559
+
560
+
// Create gallery record
561
+
const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, {
562
+
input: {
563
+
title: this._title.trim(),
564
+
...(this._description.trim() && { description: this._description.trim() }),
565
+
...(facets && { facets }),
566
+
createdAt: now
567
+
}
568
+
});
569
+
```
570
+
571
+
**Step 3: Verify syntax**
572
+
573
+
Run: `node --check src/components/pages/grain-create-gallery.js`
574
+
Expected: No syntax errors
575
+
576
+
**Step 4: Commit**
577
+
578
+
```bash
579
+
git add src/components/pages/grain-create-gallery.js
580
+
git commit -m "feat: parse facets when creating galleries"
581
+
```
582
+
583
+
---
584
+
585
+
## Task 6: Add Facets to GraphQL Queries
586
+
587
+
**Files:**
588
+
- Modify: `src/services/grain-api.js`
589
+
590
+
**Step 1: Add facets to getGalleryDetail comment query (around line 636)**
591
+
592
+
Find the comment query section in `getGalleryDetail`:
593
+
```javascript
594
+
socialGrainCommentViaSubject(
595
+
first: 20
596
+
sortBy: [{ field: createdAt, direction: ASC }]
597
+
) {
598
+
totalCount
599
+
edges {
600
+
node {
601
+
uri
602
+
text
603
+
createdAt
604
+
```
605
+
606
+
Add `facets` field after `text`:
607
+
```javascript
608
+
uri
609
+
text
610
+
facets
611
+
createdAt
612
+
```
613
+
614
+
**Step 2: Add facets field to gallery description query**
615
+
616
+
In the same `getGalleryDetail` query, find where gallery fields are queried:
617
+
```javascript
618
+
uri
619
+
did
620
+
actorHandle
621
+
title
622
+
description
623
+
createdAt
624
+
```
625
+
626
+
Add `facets` after `description`:
627
+
```javascript
628
+
uri
629
+
did
630
+
actorHandle
631
+
title
632
+
description
633
+
facets
634
+
createdAt
635
+
```
636
+
637
+
**Step 3: Update the comment transform (around line 700)**
638
+
639
+
Find the comment mapping:
640
+
```javascript
641
+
const comments = galleryNode.socialGrainCommentViaSubject?.edges?.map(edge => {
642
+
const node = edge.node;
643
+
const commentProfile = node.socialGrainActorProfileByDid;
644
+
const focusPhoto = node.focusResolved;
645
+
return {
646
+
uri: node.uri,
647
+
text: node.text,
648
+
createdAt: node.createdAt,
649
+
```
650
+
651
+
Add facets to the returned object:
652
+
```javascript
653
+
return {
654
+
uri: node.uri,
655
+
text: node.text,
656
+
facets: node.facets || [],
657
+
createdAt: node.createdAt,
658
+
```
659
+
660
+
**Step 4: Update gallery return object (around line 717)**
661
+
662
+
Find the return statement:
663
+
```javascript
664
+
return {
665
+
uri: galleryNode.uri,
666
+
title: galleryNode.title,
667
+
description: galleryNode.description,
668
+
```
669
+
670
+
Add facets:
671
+
```javascript
672
+
return {
673
+
uri: galleryNode.uri,
674
+
title: galleryNode.title,
675
+
description: galleryNode.description,
676
+
facets: galleryNode.facets || [],
677
+
```
678
+
679
+
**Step 5: Verify syntax**
680
+
681
+
Run: `node --check src/services/grain-api.js`
682
+
Expected: No syntax errors
683
+
684
+
**Step 6: Commit**
685
+
686
+
```bash
687
+
git add src/services/grain-api.js
688
+
git commit -m "feat: query facets for comments and gallery descriptions"
689
+
```
690
+
691
+
---
692
+
693
+
## Task 7: Update grain-comment to Render Facets
694
+
695
+
**Files:**
696
+
- Modify: `src/components/molecules/grain-comment.js`
697
+
698
+
**Step 1: Add import and facets property**
699
+
700
+
Add at top of file:
701
+
```javascript
702
+
import '../atoms/grain-rich-text.js';
703
+
```
704
+
705
+
Add to static properties:
706
+
```javascript
707
+
static properties = {
708
+
uri: { type: String },
709
+
handle: { type: String },
710
+
displayName: { type: String },
711
+
avatarUrl: { type: String },
712
+
text: { type: String },
713
+
facets: { type: Array }, // Add this line
714
+
createdAt: { type: String },
715
+
```
716
+
717
+
**Step 2: Initialize facets in constructor**
718
+
719
+
Add after `this.text = '';`:
720
+
```javascript
721
+
this.facets = [];
722
+
```
723
+
724
+
**Step 3: Update render method (around line 162)**
725
+
726
+
Find:
727
+
```javascript
728
+
<span class="text">${this.text}</span>
729
+
```
730
+
731
+
Replace with:
732
+
```javascript
733
+
<span class="text"><grain-rich-text .text=${this.text} .facets=${this.facets}></grain-rich-text></span>
734
+
```
735
+
736
+
**Step 4: Verify syntax**
737
+
738
+
Run: `node --check src/components/molecules/grain-comment.js`
739
+
Expected: No syntax errors
740
+
741
+
**Step 5: Commit**
742
+
743
+
```bash
744
+
git add src/components/molecules/grain-comment.js
745
+
git commit -m "feat: render comment facets with grain-rich-text"
746
+
```
747
+
748
+
---
749
+
750
+
## Task 8: Pass Facets to grain-comment in Comment Sheet
751
+
752
+
**Files:**
753
+
- Modify: `src/components/organisms/grain-comment-sheet.js`
754
+
755
+
**Step 1: Find comment rendering and add facets**
756
+
757
+
Find where `<grain-comment>` is used and add `.facets` property.
758
+
759
+
Look for pattern like:
760
+
```javascript
761
+
<grain-comment
762
+
uri=${comment.uri}
763
+
handle=${comment.handle}
764
+
...
765
+
text=${comment.text}
766
+
```
767
+
768
+
Add facets:
769
+
```javascript
770
+
.facets=${comment.facets || []}
771
+
```
772
+
773
+
**Step 2: Verify syntax**
774
+
775
+
Run: `node --check src/components/organisms/grain-comment-sheet.js`
776
+
Expected: No syntax errors
777
+
778
+
**Step 3: Commit**
779
+
780
+
```bash
781
+
git add src/components/organisms/grain-comment-sheet.js
782
+
git commit -m "feat: pass facets to grain-comment in comment sheet"
783
+
```
784
+
785
+
---
786
+
787
+
## Task 9: Update Gallery Detail to Render Description Facets
788
+
789
+
**Files:**
790
+
- Modify: `src/components/pages/grain-gallery-detail.js`
791
+
792
+
**Step 1: Add import**
793
+
794
+
Add at top:
795
+
```javascript
796
+
import '../atoms/grain-rich-text.js';
797
+
```
798
+
799
+
**Step 2: Update description rendering (around line 399)**
800
+
801
+
Find:
802
+
```javascript
803
+
${this._gallery.description ? html`
804
+
<p class="description">${this._gallery.description}</p>
805
+
` : ''}
806
+
```
807
+
808
+
Replace with:
809
+
```javascript
810
+
${this._gallery.description ? html`
811
+
<p class="description"><grain-rich-text .text=${this._gallery.description} .facets=${this._gallery.facets || []}></grain-rich-text></p>
812
+
` : ''}
813
+
```
814
+
815
+
**Step 3: Verify syntax**
816
+
817
+
Run: `node --check src/components/pages/grain-gallery-detail.js`
818
+
Expected: No syntax errors
819
+
820
+
**Step 4: Commit**
821
+
822
+
```bash
823
+
git add src/components/pages/grain-gallery-detail.js
824
+
git commit -m "feat: render gallery description facets"
825
+
```
826
+
827
+
---
828
+
829
+
## Task 10: Update Profile Header to Render Bio Facets
830
+
831
+
**Files:**
832
+
- Modify: `src/components/organisms/grain-profile-header.js`
833
+
834
+
**Step 1: Add import**
835
+
836
+
Add at top:
837
+
```javascript
838
+
import '../atoms/grain-rich-text.js';
839
+
```
840
+
841
+
**Step 2: Update bio rendering (around line 302)**
842
+
843
+
Find:
844
+
```javascript
845
+
${description ? html`<div class="bio">${description}</div>` : ''}
846
+
```
847
+
848
+
Replace with:
849
+
```javascript
850
+
${description ? html`<div class="bio"><grain-rich-text .text=${description} parse></grain-rich-text></div>` : ''}
851
+
```
852
+
853
+
Note: Using `parse` attribute since profile lexicon doesn't store facets yet.
854
+
855
+
**Step 3: Verify syntax**
856
+
857
+
Run: `node --check src/components/organisms/grain-profile-header.js`
858
+
Expected: No syntax errors
859
+
860
+
**Step 4: Commit**
861
+
862
+
```bash
863
+
git add src/components/organisms/grain-profile-header.js
864
+
git commit -m "feat: render profile bio with client-side facet parsing"
865
+
```
866
+
867
+
---
868
+
869
+
## Task 11: Final Verification
870
+
871
+
**Step 1: Check all files exist and have no syntax errors**
872
+
873
+
Run:
874
+
```bash
875
+
node --check src/lib/richtext.js && \
876
+
node --check src/components/atoms/grain-rich-text.js && \
877
+
node --check src/services/mutations.js && \
878
+
node --check src/services/grain-api.js && \
879
+
node --check src/components/pages/grain-create-gallery.js && \
880
+
node --check src/components/molecules/grain-comment.js && \
881
+
node --check src/components/pages/grain-gallery-detail.js && \
882
+
node --check src/components/organisms/grain-profile-header.js && \
883
+
echo "All files OK"
884
+
```
885
+
886
+
Expected: "All files OK"
887
+
888
+
**Step 2: Run the dev server and test manually**
889
+
890
+
Run: `npm run dev`
891
+
892
+
Test checklist:
893
+
- [ ] Create a comment with a URL - verify it becomes clickable
894
+
- [ ] Create a comment with @mention - verify it links to profile
895
+
- [ ] Create a comment with #hashtag - verify it's styled
896
+
- [ ] Create a gallery with description containing URL - verify rendering
897
+
- [ ] View a profile with URLs in bio - verify they're clickable
898
+
899
+
**Step 3: Final commit with all changes**
900
+
901
+
```bash
902
+
git add -A
903
+
git status
904
+
# If all looks good:
905
+
git commit -m "feat: complete richtext facet support for comments, galleries, and profiles"
906
+
```
907
+
908
+
---
909
+
910
+
## Summary
911
+
912
+
| File | Change |
913
+
|------|--------|
914
+
| `src/lib/richtext.js` | New - Parsing and rendering library |
915
+
| `src/components/atoms/grain-rich-text.js` | New - Display component |
916
+
| `src/services/grain-api.js` | Add `resolveHandle`, query facets |
917
+
| `src/services/mutations.js` | Parse facets on comment creation |
918
+
| `src/components/pages/grain-create-gallery.js` | Parse facets on gallery creation |
919
+
| `src/components/molecules/grain-comment.js` | Render facets |
920
+
| `src/components/organisms/grain-comment-sheet.js` | Pass facets to comment |
921
+
| `src/components/pages/grain-gallery-detail.js` | Render description facets |
922
+
| `src/components/organisms/grain-profile-header.js` | Client-side facet parsing for bio |
+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",
+3
-2
package.json
+3
-2
package.json
···
1
1
{
2
-
"name": "grain-next",
2
+
"name": "grain",
3
3
"version": "1.0.0",
4
+
"private": true,
4
5
"description": "",
5
6
"main": "index.js",
6
7
"directories": {
···
13
14
},
14
15
"keywords": [],
15
16
"author": "",
16
-
"license": "ISC",
17
+
"license": "Apache-2.0",
17
18
"dependencies": {
18
19
"@fortawesome/fontawesome-free": "^7.1.0",
19
20
"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 {
+90
src/components/atoms/grain-rich-text.js
+90
src/components/atoms/grain-rich-text.js
···
1
+
// src/components/atoms/grain-rich-text.js
2
+
import { LitElement, html, css } from 'lit';
3
+
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
4
+
import { renderFacetedText, parseTextToFacetsSync } from '../../lib/richtext.js';
5
+
import { router } from '../../router.js';
6
+
7
+
export class GrainRichText extends LitElement {
8
+
static properties = {
9
+
text: { type: String },
10
+
facets: { type: Array },
11
+
parse: { type: Boolean },
12
+
_ready: { state: true }
13
+
};
14
+
15
+
static styles = css`
16
+
:host {
17
+
display: inline;
18
+
visibility: hidden;
19
+
}
20
+
:host([ready]) {
21
+
visibility: visible;
22
+
}
23
+
.facet-link {
24
+
color: var(--color-text-primary, #fff);
25
+
font-weight: var(--font-weight-semibold, 600);
26
+
text-decoration: none;
27
+
}
28
+
.facet-link:hover {
29
+
text-decoration: underline;
30
+
}
31
+
.facet-mention {
32
+
color: var(--color-text-primary, #fff);
33
+
font-weight: var(--font-weight-semibold, 600);
34
+
text-decoration: none;
35
+
}
36
+
.facet-mention:hover {
37
+
text-decoration: underline;
38
+
}
39
+
.facet-tag {
40
+
color: var(--color-text-primary, #fff);
41
+
font-weight: var(--font-weight-semibold, 600);
42
+
}
43
+
`;
44
+
45
+
constructor() {
46
+
super();
47
+
this.text = '';
48
+
this.facets = null;
49
+
this.parse = false;
50
+
this._ready = false;
51
+
}
52
+
53
+
#handleClick = (e) => {
54
+
const link = e.target.closest('.facet-mention');
55
+
if (link) {
56
+
e.preventDefault();
57
+
const href = link.getAttribute('href');
58
+
if (href) {
59
+
router.push(href);
60
+
}
61
+
}
62
+
};
63
+
64
+
firstUpdated() {
65
+
this.renderRoot.addEventListener('click', this.#handleClick);
66
+
this.setAttribute('ready', '');
67
+
}
68
+
69
+
disconnectedCallback() {
70
+
super.disconnectedCallback();
71
+
this.renderRoot.removeEventListener('click', this.#handleClick);
72
+
}
73
+
74
+
render() {
75
+
if (!this.text) return '';
76
+
77
+
let facetsToUse = this.facets;
78
+
79
+
// If parse mode and no facets provided, parse on the fly
80
+
if (this.parse && (!this.facets || this.facets.length === 0)) {
81
+
const parsed = parseTextToFacetsSync(this.text);
82
+
facetsToUse = parsed.facets;
83
+
}
84
+
85
+
const htmlContent = renderFacetedText(this.text, facetsToUse || []);
86
+
return html`${unsafeHTML(htmlContent)}`;
87
+
}
88
+
}
89
+
90
+
customElements.define('grain-rich-text', GrainRichText);
+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
+1
-1
src/components/atoms/grain-toast.js
+1
-1
src/components/atoms/grain-toast.js
+1
-1
src/components/molecules/grain-avatar-crop.js
+1
-1
src/components/molecules/grain-avatar-crop.js
+4
-1
src/components/molecules/grain-comment.js
+4
-1
src/components/molecules/grain-comment.js
···
1
1
import { LitElement, html, css } from 'lit';
2
2
import { router } from '../../router.js';
3
3
import '../atoms/grain-avatar.js';
4
+
import '../atoms/grain-rich-text.js';
4
5
5
6
export class GrainComment extends LitElement {
6
7
static properties = {
···
9
10
displayName: { type: String },
10
11
avatarUrl: { type: String },
11
12
text: { type: String },
13
+
facets: { type: Array },
12
14
createdAt: { type: String },
13
15
isReply: { type: Boolean },
14
16
isOwner: { type: Boolean },
···
101
103
this.displayName = '';
102
104
this.avatarUrl = '';
103
105
this.text = '';
106
+
this.facets = [];
104
107
this.createdAt = '';
105
108
this.isReply = false;
106
109
this.isOwner = false;
···
159
162
<span class="handle" @click=${this.#handleProfileClick}>
160
163
${this.handle}
161
164
</span>
162
-
<span class="text">${this.text}</span>
165
+
<span class="text"><grain-rich-text .text=${this.text} .facets=${this.facets}></grain-rich-text></span>
163
166
</div>
164
167
<div class="meta">
165
168
<span class="time">${this.#formatTime(this.createdAt)}</span>
+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
`;
+102
src/components/molecules/grain-profile-header-skeleton.js
+102
src/components/molecules/grain-profile-header-skeleton.js
···
1
+
import { LitElement, html, css } from 'lit';
2
+
3
+
export class GrainProfileHeaderSkeleton extends LitElement {
4
+
static styles = css`
5
+
:host {
6
+
display: block;
7
+
padding: var(--space-md) var(--space-sm);
8
+
}
9
+
@media (min-width: 600px) {
10
+
:host {
11
+
padding-left: 0;
12
+
padding-right: 0;
13
+
}
14
+
}
15
+
.top-row {
16
+
display: flex;
17
+
align-items: flex-start;
18
+
gap: var(--space-md);
19
+
margin-bottom: var(--space-sm);
20
+
}
21
+
.right-column {
22
+
flex: 1;
23
+
min-width: 0;
24
+
padding-top: var(--space-xs);
25
+
}
26
+
.placeholder {
27
+
background: var(--color-bg-elevated);
28
+
border-radius: 4px;
29
+
animation: pulse 1.5s ease-in-out infinite;
30
+
}
31
+
.avatar {
32
+
width: var(--avatar-size-lg, 80px);
33
+
height: var(--avatar-size-lg, 80px);
34
+
border-radius: 50%;
35
+
flex-shrink: 0;
36
+
}
37
+
.handle-row {
38
+
display: flex;
39
+
align-items: center;
40
+
gap: var(--space-sm);
41
+
margin-bottom: var(--space-xs);
42
+
}
43
+
.handle {
44
+
width: 60%;
45
+
max-width: 200px;
46
+
height: 22px;
47
+
}
48
+
.menu-icon {
49
+
width: 20px;
50
+
height: 20px;
51
+
border-radius: 50%;
52
+
}
53
+
.name {
54
+
width: 40%;
55
+
max-width: 120px;
56
+
height: 16px;
57
+
margin-bottom: var(--space-xs);
58
+
}
59
+
.stats {
60
+
display: flex;
61
+
gap: var(--space-sm);
62
+
margin-bottom: var(--space-xs);
63
+
}
64
+
.stat {
65
+
width: 80px;
66
+
height: 16px;
67
+
}
68
+
.bio {
69
+
width: 70%;
70
+
max-width: 250px;
71
+
height: 16px;
72
+
margin-top: var(--space-xs);
73
+
}
74
+
@keyframes pulse {
75
+
0%, 100% { opacity: 0.4; }
76
+
50% { opacity: 1; }
77
+
}
78
+
`;
79
+
80
+
render() {
81
+
return html`
82
+
<div class="top-row">
83
+
<div class="placeholder avatar"></div>
84
+
<div class="right-column">
85
+
<div class="handle-row">
86
+
<div class="placeholder handle"></div>
87
+
<div class="placeholder menu-icon"></div>
88
+
</div>
89
+
<div class="placeholder name"></div>
90
+
<div class="stats">
91
+
<div class="placeholder stat"></div>
92
+
<div class="placeholder stat"></div>
93
+
<div class="placeholder stat"></div>
94
+
</div>
95
+
<div class="placeholder bio"></div>
96
+
</div>
97
+
</div>
98
+
`;
99
+
}
100
+
}
101
+
102
+
customElements.define('grain-profile-header-skeleton', GrainProfileHeaderSkeleton);
+37
-3
src/components/molecules/grain-pull-to-refresh.js
+37
-3
src/components/molecules/grain-pull-to-refresh.js
···
13
13
14
14
static styles = css`
15
15
:host {
16
-
display: block;
16
+
display: flex;
17
+
flex-direction: column;
18
+
flex: 1;
17
19
overflow: hidden;
20
+
min-height: 100%;
18
21
}
19
22
.container {
20
23
position: relative;
24
+
flex: 1;
25
+
display: flex;
26
+
flex-direction: column;
21
27
}
22
28
.indicator {
23
29
position: absolute;
···
41
47
42
48
#startY = 0;
43
49
#currentY = 0;
50
+
#scrollContainer = null;
44
51
45
52
constructor() {
46
53
super();
···
56
63
this.addEventListener('touchend', this.#onTouchEnd, { passive: true });
57
64
}
58
65
66
+
#findScrollContainer() {
67
+
let el = this;
68
+
while (el) {
69
+
// Get next parent, crossing shadow DOM boundaries
70
+
const parent = el.parentElement || el.getRootNode()?.host;
71
+
if (!parent || parent === document.documentElement) break;
72
+
73
+
const style = getComputedStyle(parent);
74
+
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
75
+
return parent;
76
+
}
77
+
el = parent;
78
+
}
79
+
return null;
80
+
}
81
+
82
+
#getScrollTop() {
83
+
// Find scroll container lazily (content may not be loaded at connectedCallback)
84
+
if (!this.#scrollContainer) {
85
+
this.#scrollContainer = this.#findScrollContainer();
86
+
}
87
+
if (this.#scrollContainer) {
88
+
return this.#scrollContainer.scrollTop;
89
+
}
90
+
return window.scrollY;
91
+
}
92
+
59
93
disconnectedCallback() {
60
94
this.removeEventListener('touchstart', this.#onTouchStart);
61
95
this.removeEventListener('touchmove', this.#onTouchMove);
···
68
102
69
103
#onTouchStart = (e) => {
70
104
if (this.refreshing) return;
71
-
if (window.scrollY > 0) return;
105
+
if (this.#getScrollTop() > 0) return;
72
106
73
107
this.#startY = e.touches[0].clientY;
74
108
this._pulling = true;
···
80
114
this.#currentY = e.touches[0].clientY;
81
115
const diff = this.#currentY - this.#startY;
82
116
83
-
if (diff > 0 && window.scrollY === 0) {
117
+
if (diff > 0 && this.#getScrollTop() === 0) {
84
118
e.preventDefault();
85
119
// Apply resistance
86
120
this._pullDistance = Math.min(diff * 0.5, MAX_PULL);
+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) {
+16
-16
src/components/organisms/grain-comment-sheet.js
+16
-16
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 = {
···
42
42
}
43
43
.sheet-container {
44
44
position: fixed;
45
-
bottom: 0;
45
+
bottom: calc(57px + env(safe-area-inset-bottom, 0px));
46
46
left: 0;
47
47
right: 0;
48
48
display: flex;
···
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">
···
372
371
displayName=${comment.displayName}
373
372
avatarUrl=${comment.avatarUrl}
374
373
text=${comment.text}
374
+
.facets=${comment.facets || []}
375
375
createdAt=${comment.createdAt}
376
376
?is-reply=${comment.isReply}
377
377
?isOwner=${comment.handle === auth.user?.handle}
+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}>
+3
-1
src/components/organisms/grain-header.js
+3
-1
src/components/organisms/grain-header.js
+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
+31
-7
src/components/organisms/grain-profile-header.js
+31
-7
src/components/organisms/grain-profile-header.js
···
8
8
import '../atoms/grain-icon.js';
9
9
import '../atoms/grain-spinner.js';
10
10
import '../atoms/grain-toast.js';
11
+
import '../atoms/grain-rich-text.js';
11
12
import '../molecules/grain-profile-stats.js';
12
13
13
14
export class GrainProfileHeader extends LitElement {
···
237
238
if (!this._user || this._followLoading || !this.profile) return;
238
239
239
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
+
240
258
try {
241
259
const update = await mutations.toggleFollow(
242
260
this.profile.handle,
243
261
this.profile.did,
244
-
this.profile.viewerIsFollowing,
245
-
this.profile.viewerFollowUri,
246
-
this.profile.followerCount || 0
262
+
previousState.viewerIsFollowing,
263
+
previousState.viewerFollowUri,
264
+
previousState.followerCount
247
265
);
266
+
// Update with real URI from server (needed for future unfollows)
248
267
this.profile = {
249
268
...this.profile,
250
-
viewerIsFollowing: update.viewerIsFollowing,
251
-
viewerFollowUri: update.viewerFollowUri,
252
-
followerCount: update.followerCount
269
+
viewerFollowUri: update.viewerFollowUri
253
270
};
254
271
} catch (err) {
272
+
// Rollback on failure
255
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
+
};
256
280
this.shadowRoot.querySelector('grain-toast')?.show('Failed to update');
257
281
} finally {
258
282
this._followLoading = false;
···
299
323
followerCount=${followerCount || 0}
300
324
followingCount=${followingCount || 0}
301
325
></grain-profile-stats>
302
-
${description ? html`<div class="bio">${description}</div>` : ''}
326
+
${description ? html`<div class="bio"><grain-rich-text .text=${description} parse></grain-rich-text></div>` : ''}
303
327
</div>
304
328
</div>
305
329
+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);
+103
-6
src/components/pages/grain-app.js
+103
-6
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;
25
36
font-family: var(--font-family);
26
37
background: var(--color-bg-primary);
27
38
color: var(--color-text-primary);
28
-
min-height: 100vh;
29
-
min-height: 100dvh;
39
+
height: 100vh;
40
+
height: 100dvh;
41
+
overflow: hidden;
30
42
}
31
43
#outlet {
32
-
display: block;
44
+
display: flex;
45
+
flex-direction: column;
46
+
position: fixed;
47
+
top: 48px;
48
+
left: 0;
49
+
right: 0;
50
+
bottom: calc(57px + env(safe-area-inset-bottom, 0px));
51
+
overflow-y: auto;
52
+
-webkit-overflow-scrolling: touch;
53
+
}
54
+
#outlet > * {
55
+
flex: 0 0 auto;
56
+
min-height: 100%;
33
57
}
34
58
`;
35
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
+
36
128
firstUpdated() {
37
129
const outlet = this.shadowRoot.getElementById('outlet');
38
130
···
44
136
.register('/profile/:handle', 'grain-profile')
45
137
.register('/settings', 'grain-settings')
46
138
.register('/settings/profile', 'grain-edit-profile')
47
-
.register('/settings/terms', 'grain-terms')
48
-
.register('/settings/privacy', 'grain-privacy')
49
-
.register('/settings/copyright', 'grain-copyright')
139
+
.register('/legal/terms', 'grain-terms')
140
+
.register('/legal/privacy', 'grain-privacy')
141
+
.register('/legal/copyright', 'grain-copyright')
50
142
.register('/create', 'grain-create-gallery')
143
+
.register('/create/descriptions', 'grain-image-descriptions')
51
144
.register('/explore', 'grain-explore')
52
145
.register('/notifications', 'grain-notifications')
146
+
.register('/onboarding', 'grain-onboarding')
147
+
.register('/oauth/callback', 'grain-oauth-callback')
53
148
.register('*', 'grain-timeline')
54
149
.connect(outlet);
55
150
}
···
59
154
<grain-header></grain-header>
60
155
<div id="outlet"></div>
61
156
<grain-bottom-nav></grain-bottom-nav>
157
+
${this.#renderDialog()}
158
+
<grain-toast></grain-toast>
62
159
`;
63
160
}
64
161
}
+3
-3
src/components/pages/grain-copyright.js
+3
-3
src/components/pages/grain-copyright.js
···
5
5
static styles = css`
6
6
:host {
7
7
display: block;
8
+
width: 100%;
8
9
max-width: var(--feed-max-width);
9
-
margin: 0 auto;
10
-
min-height: 100vh;
11
-
min-height: 100dvh;
10
+
min-height: 100%;
12
11
padding-bottom: 80px;
13
12
background: var(--color-bg-primary);
13
+
align-self: center;
14
14
}
15
15
.header {
16
16
display: flex;
+31
-132
src/components/pages/grain-create-gallery.js
+31
-132
src/components/pages/grain-create-gallery.js
···
8
8
import '../atoms/grain-textarea.js';
9
9
import '../molecules/grain-form-field.js';
10
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
11
export class GrainCreateGallery extends LitElement {
46
12
static properties = {
47
13
_photos: { state: true },
48
14
_title: { state: true },
49
-
_description: { state: true },
50
-
_posting: { state: true },
51
-
_error: { state: true }
15
+
_description: { state: true }
52
16
};
53
17
54
18
static styles = css`
55
19
:host {
56
20
display: block;
21
+
width: 100%;
57
22
max-width: var(--feed-max-width);
58
-
margin: 0 auto;
59
-
min-height: 100vh;
60
-
min-height: 100dvh;
23
+
min-height: 100%;
61
24
background: var(--color-bg-primary);
25
+
align-self: center;
62
26
}
63
27
.header {
64
28
display: flex;
···
120
84
.form {
121
85
padding: var(--space-sm);
122
86
}
123
-
.error {
124
-
color: #ff4444;
125
-
padding: var(--space-sm);
126
-
text-align: center;
127
-
}
128
87
`;
129
88
130
89
constructor() {
···
132
91
this._photos = [];
133
92
this._title = '';
134
93
this._description = '';
135
-
this._posting = false;
136
-
this._error = null;
137
94
}
138
95
139
96
connectedCallback() {
140
97
super.connectedCallback();
98
+
99
+
if (!auth.isAuthenticated) {
100
+
router.replace('/');
101
+
return;
102
+
}
103
+
141
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
+
142
110
if (!this._photos.length) {
143
111
router.push('/');
144
112
}
···
147
115
#handleBack() {
148
116
if (confirm('Discard this gallery?')) {
149
117
draftGallery.clear();
118
+
sessionStorage.removeItem('draft_title');
119
+
sessionStorage.removeItem('draft_description');
150
120
history.back();
151
121
}
152
122
}
153
123
154
124
#removePhoto(index) {
155
125
this._photos = this._photos.filter((_, i) => i !== index);
126
+
draftGallery.setPhotos(this._photos);
156
127
if (this._photos.length === 0) {
157
128
draftGallery.clear();
129
+
sessionStorage.removeItem('draft_title');
130
+
sessionStorage.removeItem('draft_description');
158
131
router.push('/');
159
132
}
160
133
}
···
167
140
this._description = e.detail.value.slice(0, 1000);
168
141
}
169
142
170
-
get #canPost() {
171
-
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;
172
145
}
173
146
174
-
async #handlePost() {
175
-
if (!this.#canPost) return;
147
+
#handleNext() {
148
+
if (!this.#canProceed) return;
176
149
177
-
this._posting = true;
178
-
this._error = null;
150
+
sessionStorage.setItem('draft_title', this._title);
151
+
sessionStorage.setItem('draft_description', this._description);
152
+
draftGallery.setPhotos(this._photos);
179
153
180
-
try {
181
-
const client = auth.getClient();
182
-
const now = new Date().toISOString();
183
-
184
-
// Upload photos and create photo records
185
-
const photoUris = [];
186
-
for (const photo of this._photos) {
187
-
// Upload blob
188
-
const base64Data = photo.dataUrl.split(',')[1];
189
-
const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, {
190
-
data: base64Data,
191
-
mimeType: 'image/jpeg'
192
-
});
193
-
194
-
if (!uploadResult.uploadBlob) {
195
-
throw new Error('Failed to upload image');
196
-
}
197
-
198
-
// Create photo record
199
-
const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, {
200
-
input: {
201
-
photo: {
202
-
$type: 'blob',
203
-
ref: { $link: uploadResult.uploadBlob.ref },
204
-
mimeType: uploadResult.uploadBlob.mimeType,
205
-
size: uploadResult.uploadBlob.size
206
-
},
207
-
aspectRatio: {
208
-
width: photo.width,
209
-
height: photo.height
210
-
},
211
-
createdAt: now
212
-
}
213
-
});
214
-
215
-
photoUris.push(photoResult.createSocialGrainPhoto.uri);
216
-
}
217
-
218
-
// Create gallery record
219
-
const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, {
220
-
input: {
221
-
title: this._title.trim(),
222
-
...(this._description.trim() && { description: this._description.trim() }),
223
-
createdAt: now
224
-
}
225
-
});
226
-
227
-
const galleryUri = galleryResult.createSocialGrainGallery.uri;
228
-
229
-
// Create gallery items linking photos to gallery
230
-
for (let i = 0; i < photoUris.length; i++) {
231
-
await client.mutate(CREATE_GALLERY_ITEM_MUTATION, {
232
-
input: {
233
-
gallery: galleryUri,
234
-
item: photoUris[i],
235
-
position: i,
236
-
createdAt: now
237
-
}
238
-
});
239
-
}
240
-
241
-
// Clear draft and navigate to new gallery
242
-
draftGallery.clear();
243
-
const rkey = galleryUri.split('/').pop();
244
-
router.push(`/profile/${auth.user.handle}/gallery/${rkey}`);
245
-
246
-
} catch (err) {
247
-
console.error('Failed to create gallery:', err);
248
-
this._error = err.message || 'Failed to create gallery. Please try again.';
249
-
} finally {
250
-
this._posting = false;
251
-
}
154
+
router.push('/create/descriptions');
252
155
}
253
156
254
157
render() {
···
261
164
<span class="header-title">Create a gallery</span>
262
165
</div>
263
166
<grain-button
264
-
?disabled=${!this.#canPost}
265
-
?loading=${this._posting}
266
-
loadingText="Posting..."
267
-
@click=${this.#handlePost}
268
-
>Post</grain-button>
167
+
?disabled=${!this.#canProceed}
168
+
@click=${this.#handleNext}
169
+
>Next</grain-button>
269
170
</div>
270
171
271
172
<div class="photo-strip">
···
276
177
</div>
277
178
`)}
278
179
</div>
279
-
280
-
${this._error ? html`<p class="error">${this._error}</p>` : ''}
281
180
282
181
<div class="form">
283
182
<grain-form-field .value=${this._title} .maxlength=${100}>
+30
-3
src/components/pages/grain-edit-profile.js
+30
-3
src/components/pages/grain-edit-profile.js
···
2
2
import { router } from '../../router.js';
3
3
import { auth } from '../../services/auth.js';
4
4
import { grainApi } from '../../services/grain-api.js';
5
+
import { recordCache } from '../../services/record-cache.js';
5
6
import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js';
6
7
import '../atoms/grain-icon.js';
7
8
import '../atoms/grain-button.js';
···
47
48
static styles = css`
48
49
:host {
49
50
display: block;
51
+
width: 100%;
50
52
max-width: var(--feed-max-width);
51
-
margin: 0 auto;
52
-
min-height: 100vh;
53
-
min-height: 100dvh;
53
+
min-height: 100%;
54
54
padding-bottom: 80px;
55
55
background: var(--color-bg-primary);
56
+
align-self: center;
56
57
}
57
58
.header {
58
59
display: flex;
···
175
176
176
177
async connectedCallback() {
177
178
super.connectedCallback();
179
+
180
+
// Redirect to timeline if not authenticated
181
+
if (!auth.isAuthenticated) {
182
+
router.replace('/');
183
+
return;
184
+
}
185
+
178
186
await this.#loadProfile();
179
187
}
180
188
···
318
326
319
327
// Refresh user data in header
320
328
await auth.refreshUser();
329
+
330
+
// Update profile cache if it exists (merge preserves galleries etc)
331
+
if (recordCache.has(`profile:${auth.user.handle}`)) {
332
+
// Determine avatar URL: new upload uses refreshed URL, removal uses null, unchanged keeps original
333
+
let avatarUrl;
334
+
if (this._newAvatarDataUrl) {
335
+
avatarUrl = auth.user.avatar;
336
+
} else if (this._avatarRemoved) {
337
+
avatarUrl = null;
338
+
} else {
339
+
avatarUrl = this._avatarUrl;
340
+
}
341
+
342
+
recordCache.set(`profile:${auth.user.handle}`, {
343
+
displayName: this._displayName.trim() || null,
344
+
description: this._description.trim() || null,
345
+
avatarUrl
346
+
});
347
+
}
321
348
322
349
// Go back to settings
323
350
history.back();
+8
-1
src/components/pages/grain-explore.js
+8
-1
src/components/pages/grain-explore.js
···
23
23
}
24
24
.search-container {
25
25
position: sticky;
26
-
top: 48px;
26
+
top: 0;
27
27
background: var(--color-bg-primary);
28
28
padding: var(--space-sm);
29
29
z-index: 10;
30
+
}
31
+
.search-container::after {
32
+
content: '';
33
+
display: block;
34
+
max-width: var(--feed-max-width);
35
+
margin: 0 auto;
30
36
border-bottom: 1px solid var(--color-border);
37
+
margin-top: var(--space-sm);
31
38
}
32
39
.search-container grain-input {
33
40
max-width: var(--feed-max-width);
+48
-29
src/components/pages/grain-gallery-detail.js
+48
-29
src/components/pages/grain-gallery-detail.js
···
11
11
import '../organisms/grain-comment-sheet.js';
12
12
import '../atoms/grain-spinner.js';
13
13
import '../atoms/grain-icon.js';
14
-
import '../organisms/grain-action-dialog.js';
14
+
import '../atoms/grain-rich-text.js';
15
15
16
16
const DELETE_GALLERY_MUTATION = `
17
17
mutation DeleteGallery($rkey: String!) {
···
50
50
_gallery: { state: true },
51
51
_loading: { state: true },
52
52
_error: { state: true },
53
-
_menuOpen: { state: true },
54
-
_deleting: { state: true },
55
53
_commentSheetOpen: { state: true },
56
54
_focusPhotoUri: { state: true },
57
55
_focusPhotoUrl: { state: true }
···
138
136
this._gallery = null;
139
137
this._loading = true;
140
138
this._error = null;
141
-
this._menuOpen = false;
142
-
this._deleting = false;
143
139
this._commentSheetOpen = false;
144
140
this._focusPhotoUri = null;
145
141
this._focusPhotoUrl = null;
···
148
144
connectedCallback() {
149
145
super.connectedCallback();
150
146
this.#loadGallery();
147
+
document.addEventListener('dialog-action', this.#handleDialogAction);
151
148
}
152
149
153
150
disconnectedCallback() {
154
151
if (this.#currentUri) {
155
152
recordCache.unsubscribe(this.#currentUri, this.#onCacheUpdate);
156
153
}
154
+
document.removeEventListener('dialog-action', this.#handleDialogAction);
157
155
super.disconnectedCallback();
158
156
}
159
157
···
303
301
}
304
302
305
303
#handleMenuOpen() {
306
-
this._menuOpen = true;
307
-
}
308
-
309
-
#handleMenuClose() {
310
-
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
+
}));
311
316
}
312
317
313
-
async #handleAction(e) {
318
+
#handleDialogAction = (e) => {
314
319
if (e.detail.action === 'delete') {
315
-
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
+
}));
316
330
}
317
-
}
331
+
};
318
332
319
333
async #handleDelete() {
320
-
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
+
321
348
try {
322
349
const client = auth.getClient();
323
350
···
339
366
340
367
// Delete the gallery
341
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 }));
342
372
router.push(`/profile/${this.handle}`);
343
373
} catch (err) {
344
374
console.error('Failed to delete gallery:', err);
345
375
this._error = 'Failed to delete gallery. Please try again.';
346
-
this._menuOpen = false;
347
-
} finally {
348
-
this._deleting = false;
376
+
this.dispatchEvent(new CustomEvent('close-dialog', { bubbles: true, composed: true }));
349
377
}
350
378
}
351
379
···
357
385
<grain-icon name="back" size="20"></grain-icon>
358
386
</button>
359
387
<div class="header-spacer"></div>
360
-
${this.#isOwner ? html`
388
+
${auth.isAuthenticated ? html`
361
389
<button class="menu-button" @click=${this.#handleMenuOpen}>
362
-
<grain-icon name="ellipsisVertical" size="20"></grain-icon>
390
+
<grain-icon name="ellipsis" size="20"></grain-icon>
363
391
</button>
364
392
` : ''}
365
393
</div>
···
397
425
<div class="content">
398
426
<p class="title">${this._gallery.title}</p>
399
427
${this._gallery.description ? html`
400
-
<p class="description">${this._gallery.description}</p>
428
+
<p class="description"><grain-rich-text .text=${this._gallery.description} .facets=${this._gallery.facets || []}></grain-rich-text></p>
401
429
` : ''}
402
430
<time class="timestamp">${this.#formatDate(this._gallery.createdAt)}</time>
403
431
</div>
404
432
` : ''}
405
-
406
-
<grain-action-dialog
407
-
?open=${this._menuOpen}
408
-
?loading=${this._deleting}
409
-
loadingText="Deleting..."
410
-
.actions=${[{ label: 'Delete', action: 'delete', danger: true }]}
411
-
@action=${this.#handleAction}
412
-
@close=${this.#handleMenuClose}
413
-
></grain-action-dialog>
414
433
415
434
<grain-comment-sheet
416
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);
+47
-15
src/components/pages/grain-notifications.js
+47
-15
src/components/pages/grain-notifications.js
···
3
3
import { grainApi } from '../../services/grain-api.js';
4
4
import { router } from '../../router.js';
5
5
import '../templates/grain-feed-layout.js';
6
+
import '../molecules/grain-pull-to-refresh.js';
6
7
import '../atoms/grain-spinner.js';
7
8
import '../atoms/grain-avatar.js';
8
9
···
10
11
static properties = {
11
12
_notifications: { state: true },
12
13
_loading: { state: true },
14
+
_refreshing: { state: true },
13
15
_error: { state: true },
14
16
_user: { state: true },
15
17
_hasMore: { state: true },
···
26
28
padding: var(--space-sm);
27
29
border-bottom: 1px solid var(--color-border);
28
30
position: sticky;
29
-
top: 48px;
31
+
top: 0;
30
32
background: var(--color-bg-primary);
31
33
z-index: 10;
32
34
}
···
132
134
super();
133
135
this._notifications = [];
134
136
this._loading = true;
137
+
this._refreshing = false;
135
138
this._error = null;
136
139
this._user = auth.user;
137
140
this._hasMore = true;
···
140
143
141
144
connectedCallback() {
142
145
super.connectedCallback();
146
+
147
+
// Redirect to timeline if not authenticated
148
+
if (!auth.isAuthenticated) {
149
+
router.replace('/');
150
+
return;
151
+
}
152
+
143
153
this._unsubscribe = auth.subscribe(user => {
144
154
this._user = user;
145
155
if (user) {
···
223
233
}
224
234
}
225
235
236
+
async #handleRefresh() {
237
+
if (!this._user?.did) return;
238
+
239
+
this._refreshing = true;
240
+
try {
241
+
const result = await grainApi.getNotifications(this._user.did, { first: 20 });
242
+
this._notifications = result.notifications;
243
+
this._hasMore = result.pageInfo.hasNextPage;
244
+
this._cursor = result.pageInfo.endCursor;
245
+
this._error = null;
246
+
} catch (err) {
247
+
console.error('Failed to refresh notifications:', err);
248
+
this._error = err.message;
249
+
} finally {
250
+
this._refreshing = false;
251
+
}
252
+
}
253
+
226
254
#formatRelativeTime(dateStr) {
227
255
const date = new Date(dateStr);
228
256
const now = new Date();
···
387
415
return html`
388
416
<grain-feed-layout>
389
417
<div class="header">Notifications</div>
390
-
391
-
${this._error ? html`
392
-
<p class="error">${this._error}</p>
393
-
` : ''}
418
+
<grain-pull-to-refresh
419
+
?refreshing=${this._refreshing}
420
+
@refresh=${this.#handleRefresh}
421
+
>
422
+
${this._error ? html`
423
+
<p class="error">${this._error}</p>
424
+
` : ''}
394
425
395
-
${!this._loading && !this._error && this._notifications.length === 0 ? html`
396
-
<p class="empty">No notifications yet</p>
397
-
` : ''}
426
+
${!this._loading && !this._error && this._notifications.length === 0 ? html`
427
+
<p class="empty">No notifications yet</p>
428
+
` : ''}
398
429
399
-
${this._notifications.length > 0 ? html`
400
-
<ul class="notification-list">
401
-
${this._notifications.map(n => this.#renderNotification(n))}
402
-
</ul>
403
-
` : ''}
430
+
${this._notifications.length > 0 ? html`
431
+
<ul class="notification-list">
432
+
${this._notifications.map(n => this.#renderNotification(n))}
433
+
</ul>
434
+
` : ''}
404
435
405
-
<div id="sentinel"></div>
436
+
<div id="sentinel"></div>
406
437
407
-
${this._loading ? html`<grain-spinner></grain-spinner>` : ''}
438
+
${this._loading ? html`<grain-spinner></grain-spinner>` : ''}
439
+
</grain-pull-to-refresh>
408
440
</grain-feed-layout>
409
441
`;
410
442
}
+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);
+3
-3
src/components/pages/grain-privacy.js
+3
-3
src/components/pages/grain-privacy.js
···
5
5
static styles = css`
6
6
:host {
7
7
display: block;
8
+
width: 100%;
8
9
max-width: var(--feed-max-width);
9
-
margin: 0 auto;
10
-
min-height: 100vh;
11
-
min-height: 100dvh;
10
+
min-height: 100%;
12
11
padding-bottom: 80px;
13
12
background: var(--color-bg-primary);
13
+
align-self: center;
14
14
}
15
15
.header {
16
16
display: flex;
+21
-3
src/components/pages/grain-profile.js
+21
-3
src/components/pages/grain-profile.js
···
6
6
import '../organisms/grain-gallery-grid.js';
7
7
import '../molecules/grain-pull-to-refresh.js';
8
8
import '../molecules/grain-avatar-crop.js';
9
+
import '../molecules/grain-profile-header-skeleton.js';
9
10
import '../atoms/grain-spinner.js';
10
11
import '../organisms/grain-action-dialog.js';
11
12
···
25
26
26
27
static styles = css`
27
28
:host {
28
-
display: block;
29
+
display: flex;
30
+
flex-direction: column;
31
+
min-height: 100%;
29
32
}
30
33
.error {
31
34
padding: var(--space-lg);
···
119
122
} else {
120
123
this.#fetchProfile();
121
124
}
125
+
126
+
// Subscribe to cache updates
127
+
this.#cacheCallback = (data) => {
128
+
this._profile = { ...this._profile, ...data };
129
+
};
130
+
recordCache.subscribe(`profile:${this.handle}`, this.#cacheCallback);
122
131
}
123
132
133
+
disconnectedCallback() {
134
+
super.disconnectedCallback();
135
+
if (this.#cacheCallback) {
136
+
recordCache.unsubscribe(`profile:${this.handle}`, this.#cacheCallback);
137
+
}
138
+
}
139
+
140
+
#cacheCallback = null;
141
+
124
142
async #fetchProfile() {
125
143
try {
126
144
this._error = null;
···
151
169
?refreshing=${this._refreshing}
152
170
@refresh=${this.#handleRefresh}
153
171
>
154
-
${this._loading ? html`<grain-spinner></grain-spinner>` : ''}
172
+
${this._loading ? html`<grain-profile-header-skeleton></grain-profile-header-skeleton>` : ''}
155
173
156
174
${this._error ? html`
157
175
<p class="error">${this._error}</p>
···
166
184
@avatar-updated=${this.#handleRefresh}
167
185
></grain-profile-header>
168
186
169
-
${this._profile.galleries.length > 0 ? html`
187
+
${this._profile.galleries?.length > 0 ? html`
170
188
<grain-gallery-grid
171
189
handle=${this.handle}
172
190
.galleries=${this._profile.galleries}
+14
-7
src/components/pages/grain-settings.js
+14
-7
src/components/pages/grain-settings.js
···
12
12
static styles = css`
13
13
:host {
14
14
display: block;
15
+
width: 100%;
15
16
max-width: var(--feed-max-width);
16
-
margin: 0 auto;
17
-
min-height: 100vh;
18
-
min-height: 100dvh;
17
+
min-height: 100%;
19
18
padding-bottom: 80px;
20
19
background: var(--color-bg-primary);
20
+
align-self: center;
21
21
}
22
22
.header {
23
23
display: flex;
···
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() {
+3
-3
src/components/pages/grain-terms.js
+3
-3
src/components/pages/grain-terms.js
···
5
5
static styles = css`
6
6
:host {
7
7
display: block;
8
+
width: 100%;
8
9
max-width: var(--feed-max-width);
9
-
margin: 0 auto;
10
-
min-height: 100vh;
11
-
min-height: 100dvh;
10
+
min-height: 100%;
12
11
padding-bottom: 80px;
13
12
background: var(--color-bg-primary);
13
+
align-self: center;
14
14
}
15
15
.header {
16
16
display: flex;
+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
}
+8
-4
src/components/templates/grain-feed-layout.js
+8
-4
src/components/templates/grain-feed-layout.js
···
3
3
export class GrainFeedLayout extends LitElement {
4
4
static styles = css`
5
5
:host {
6
-
display: block;
6
+
display: flex;
7
+
flex-direction: column;
8
+
flex: 1;
9
+
width: 100%;
7
10
max-width: var(--feed-max-width);
8
11
margin: 0 auto;
9
-
min-height: 100vh;
10
-
min-height: 100dvh;
11
-
padding-bottom: calc(48px + env(safe-area-inset-bottom));
12
+
min-height: 100%;
12
13
background: var(--color-bg-primary);
14
+
}
15
+
::slotted(grain-pull-to-refresh) {
16
+
flex: 1;
13
17
}
14
18
`;
15
19
+311
src/lib/richtext.js
+311
src/lib/richtext.js
···
1
+
// src/lib/richtext.js - Bluesky-compatible richtext parsing and rendering
2
+
3
+
/**
4
+
* Parse text for Bluesky facets: mentions, links, hashtags.
5
+
* Returns { text, facets } with byte-indexed positions.
6
+
*
7
+
* @param {string} text - Plain text to parse
8
+
* @param {function} resolveHandle - Optional async function to resolve @handle to DID
9
+
* @returns {Promise<{ text: string, facets: Array }>}
10
+
*/
11
+
export async function parseTextToFacets(text, resolveHandle = null) {
12
+
if (!text) return { text: '', facets: [] };
13
+
14
+
const facets = [];
15
+
const encoder = new TextEncoder();
16
+
17
+
function getByteOffset(str, charIndex) {
18
+
return encoder.encode(str.slice(0, charIndex)).length;
19
+
}
20
+
21
+
// Track claimed positions to avoid overlaps
22
+
const claimedPositions = new Set();
23
+
24
+
function isRangeClaimed(start, end) {
25
+
for (let i = start; i < end; i++) {
26
+
if (claimedPositions.has(i)) return true;
27
+
}
28
+
return false;
29
+
}
30
+
31
+
function claimRange(start, end) {
32
+
for (let i = start; i < end; i++) {
33
+
claimedPositions.add(i);
34
+
}
35
+
}
36
+
37
+
// URLs first (highest priority)
38
+
const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g;
39
+
let urlMatch;
40
+
while ((urlMatch = urlRegex.exec(text)) !== null) {
41
+
const start = urlMatch.index;
42
+
const end = start + urlMatch[0].length;
43
+
44
+
if (!isRangeClaimed(start, end)) {
45
+
claimRange(start, end);
46
+
facets.push({
47
+
index: {
48
+
byteStart: getByteOffset(text, start),
49
+
byteEnd: getByteOffset(text, end),
50
+
},
51
+
features: [{
52
+
$type: 'app.bsky.richtext.facet#link',
53
+
uri: urlMatch[0],
54
+
}],
55
+
});
56
+
}
57
+
}
58
+
59
+
// Mentions: @handle or @handle.domain.tld
60
+
const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g;
61
+
let mentionMatch;
62
+
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
63
+
const start = mentionMatch.index;
64
+
const end = start + mentionMatch[0].length;
65
+
const handle = mentionMatch[0].slice(1); // Remove @
66
+
67
+
if (!isRangeClaimed(start, end)) {
68
+
// Try to resolve handle to DID
69
+
let did = null;
70
+
if (resolveHandle) {
71
+
try {
72
+
did = await resolveHandle(handle);
73
+
} catch (e) {
74
+
// Handle not found - skip this mention
75
+
continue;
76
+
}
77
+
}
78
+
79
+
if (did) {
80
+
claimRange(start, end);
81
+
facets.push({
82
+
index: {
83
+
byteStart: getByteOffset(text, start),
84
+
byteEnd: getByteOffset(text, end),
85
+
},
86
+
features: [{
87
+
$type: 'app.bsky.richtext.facet#mention',
88
+
did,
89
+
}],
90
+
});
91
+
}
92
+
}
93
+
}
94
+
95
+
// Hashtags: #tag (alphanumeric, no leading numbers)
96
+
const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g;
97
+
let hashtagMatch;
98
+
while ((hashtagMatch = hashtagRegex.exec(text)) !== null) {
99
+
const start = hashtagMatch.index;
100
+
const end = start + hashtagMatch[0].length;
101
+
const tag = hashtagMatch[1]; // Without #
102
+
103
+
if (!isRangeClaimed(start, end)) {
104
+
claimRange(start, end);
105
+
facets.push({
106
+
index: {
107
+
byteStart: getByteOffset(text, start),
108
+
byteEnd: getByteOffset(text, end),
109
+
},
110
+
features: [{
111
+
$type: 'app.bsky.richtext.facet#tag',
112
+
tag,
113
+
}],
114
+
});
115
+
}
116
+
}
117
+
118
+
// Sort by byte position
119
+
facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
120
+
121
+
return { text, facets };
122
+
}
123
+
124
+
/**
125
+
* Synchronous parsing for client-side render (no DID resolution).
126
+
* Mentions display as-is without profile links.
127
+
*/
128
+
export function parseTextToFacetsSync(text) {
129
+
if (!text) return { text: '', facets: [] };
130
+
131
+
const facets = [];
132
+
const encoder = new TextEncoder();
133
+
134
+
function getByteOffset(str, charIndex) {
135
+
return encoder.encode(str.slice(0, charIndex)).length;
136
+
}
137
+
138
+
const claimedPositions = new Set();
139
+
140
+
function isRangeClaimed(start, end) {
141
+
for (let i = start; i < end; i++) {
142
+
if (claimedPositions.has(i)) return true;
143
+
}
144
+
return false;
145
+
}
146
+
147
+
function claimRange(start, end) {
148
+
for (let i = start; i < end; i++) {
149
+
claimedPositions.add(i);
150
+
}
151
+
}
152
+
153
+
// URLs
154
+
const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g;
155
+
let urlMatch;
156
+
while ((urlMatch = urlRegex.exec(text)) !== null) {
157
+
const start = urlMatch.index;
158
+
const end = start + urlMatch[0].length;
159
+
160
+
if (!isRangeClaimed(start, end)) {
161
+
claimRange(start, end);
162
+
facets.push({
163
+
index: {
164
+
byteStart: getByteOffset(text, start),
165
+
byteEnd: getByteOffset(text, end),
166
+
},
167
+
features: [{
168
+
$type: 'app.bsky.richtext.facet#link',
169
+
uri: urlMatch[0],
170
+
}],
171
+
});
172
+
}
173
+
}
174
+
175
+
// Mentions: @handle or @handle.domain.tld (no DID resolution in sync mode)
176
+
const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g;
177
+
let mentionMatch;
178
+
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
179
+
const start = mentionMatch.index;
180
+
const end = start + mentionMatch[0].length;
181
+
182
+
if (!isRangeClaimed(start, end)) {
183
+
claimRange(start, end);
184
+
facets.push({
185
+
index: {
186
+
byteStart: getByteOffset(text, start),
187
+
byteEnd: getByteOffset(text, end),
188
+
},
189
+
features: [{
190
+
$type: 'app.bsky.richtext.facet#mention',
191
+
did: null, // No DID in sync mode
192
+
}],
193
+
});
194
+
}
195
+
}
196
+
197
+
// Hashtags
198
+
const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g;
199
+
let hashtagMatch;
200
+
while ((hashtagMatch = hashtagRegex.exec(text)) !== null) {
201
+
const start = hashtagMatch.index;
202
+
const end = start + hashtagMatch[0].length;
203
+
const tag = hashtagMatch[1];
204
+
205
+
if (!isRangeClaimed(start, end)) {
206
+
claimRange(start, end);
207
+
facets.push({
208
+
index: {
209
+
byteStart: getByteOffset(text, start),
210
+
byteEnd: getByteOffset(text, end),
211
+
},
212
+
features: [{
213
+
$type: 'app.bsky.richtext.facet#tag',
214
+
tag,
215
+
}],
216
+
});
217
+
}
218
+
}
219
+
220
+
facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
221
+
return { text, facets };
222
+
}
223
+
224
+
/**
225
+
* Render text with facets as HTML.
226
+
*
227
+
* @param {string} text - The text content
228
+
* @param {Array} facets - Array of facet objects
229
+
* @param {Object} options - Rendering options
230
+
* @returns {string} HTML string
231
+
*/
232
+
export function renderFacetedText(text, facets, options = {}) {
233
+
if (!text) return '';
234
+
235
+
// If no facets, just escape and return
236
+
if (!facets || facets.length === 0) {
237
+
return escapeHtml(text);
238
+
}
239
+
240
+
const encoder = new TextEncoder();
241
+
const decoder = new TextDecoder();
242
+
const bytes = encoder.encode(text);
243
+
244
+
// Sort facets by start position
245
+
const sortedFacets = [...facets].sort(
246
+
(a, b) => a.index.byteStart - b.index.byteStart
247
+
);
248
+
249
+
let result = '';
250
+
let lastEnd = 0;
251
+
252
+
for (const facet of sortedFacets) {
253
+
// Validate byte indices
254
+
if (facet.index.byteStart < 0 || facet.index.byteEnd > bytes.length) {
255
+
continue; // Skip invalid facets
256
+
}
257
+
258
+
// Add text before this facet
259
+
if (facet.index.byteStart > lastEnd) {
260
+
const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart);
261
+
result += escapeHtml(decoder.decode(beforeBytes));
262
+
}
263
+
264
+
// Get the faceted text
265
+
const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd);
266
+
const facetText = decoder.decode(facetBytes);
267
+
268
+
// Determine facet type and render
269
+
const feature = facet.features?.[0];
270
+
if (!feature) {
271
+
result += escapeHtml(facetText);
272
+
lastEnd = facet.index.byteEnd;
273
+
continue;
274
+
}
275
+
276
+
const type = feature.$type || feature.__typename || '';
277
+
278
+
if (type.includes('link')) {
279
+
const uri = feature.uri || '';
280
+
result += `<a href="${escapeHtml(uri)}" target="_blank" rel="noopener noreferrer" class="facet-link">${escapeHtml(facetText)}</a>`;
281
+
} else if (type.includes('mention')) {
282
+
// Extract handle from text (remove @)
283
+
const handle = facetText.startsWith('@') ? facetText.slice(1) : facetText;
284
+
result += `<a href="/profile/${escapeHtml(handle)}" class="facet-mention">${escapeHtml(facetText)}</a>`;
285
+
} else if (type.includes('tag')) {
286
+
// Hashtag - styled but not clickable for now
287
+
result += `<span class="facet-tag">${escapeHtml(facetText)}</span>`;
288
+
} else {
289
+
result += escapeHtml(facetText);
290
+
}
291
+
292
+
lastEnd = facet.index.byteEnd;
293
+
}
294
+
295
+
// Add remaining text
296
+
if (lastEnd < bytes.length) {
297
+
const remainingBytes = bytes.slice(lastEnd);
298
+
result += escapeHtml(decoder.decode(remainingBytes));
299
+
}
300
+
301
+
return result;
302
+
}
303
+
304
+
function escapeHtml(text) {
305
+
return text
306
+
.replace(/&/g, '&')
307
+
.replace(/</g, '<')
308
+
.replace(/>/g, '>')
309
+
.replace(/"/g, '"')
310
+
.replace(/'/g, ''');
311
+
}
+14
-13
src/router.js
+14
-13
src/router.js
···
5
5
#pageCache = new Map(); // path -> { element }
6
6
#scrollCache = new Map(); // path -> scrollY (persists after element eviction)
7
7
8
-
// Only cache these route patterns (timeline and profiles)
8
+
// Only cache these route patterns (timeline, profiles, notifications, explore)
9
9
#cacheablePatterns = [
10
10
/^\/$/, // timeline
11
-
/^\/profile\/[^/]+$/ // profile (not followers/following/gallery)
11
+
/^\/profile\/[^/]+$/, // profile (not followers/following/gallery)
12
+
/^\/notifications$/, // notifications
13
+
/^\/explore$/ // explore/search
12
14
];
13
15
14
16
register(path, componentTag) {
···
18
20
19
21
connect(outlet) {
20
22
this.#outlet = outlet;
21
-
window.addEventListener('popstate', () => {
22
-
if (document.startViewTransition) {
23
-
document.startViewTransition(() => this.#navigate());
24
-
} else {
25
-
this.#navigate();
26
-
}
27
-
});
23
+
// Skip View Transitions for popstate - browser gestures provide their own
24
+
window.addEventListener('popstate', () => this.#navigate());
28
25
this.#navigate();
29
26
return this;
30
27
}
···
98
95
const pathname = location.pathname;
99
96
100
97
// Save scroll position of current page before switching
101
-
if (this.#currentPath) {
102
-
this.#scrollCache.set(this.#currentPath, window.scrollY);
98
+
if (this.#currentPath && this.#outlet) {
99
+
this.#scrollCache.set(this.#currentPath, this.#outlet.scrollTop);
103
100
}
104
101
105
102
// Skip if same path
···
130
127
cached.element.dispatchEvent(new CustomEvent('grain:activated'));
131
128
// Restore scroll position after paint
132
129
requestAnimationFrame(() => {
133
-
window.scrollTo(0, this.#scrollCache.get(pathname) || 0);
130
+
if (this.#outlet) {
131
+
this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0;
132
+
}
134
133
});
135
134
return;
136
135
}
···
148
147
149
148
// Restore saved scroll position, or start at top for new pages
150
149
requestAnimationFrame(() => {
151
-
window.scrollTo(0, this.#scrollCache.get(pathname) || 0);
150
+
if (this.#outlet) {
151
+
this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0;
152
+
}
152
153
});
153
154
}
154
155
}
+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() {
+125
-1
src/services/grain-api.js
+125
-1
src/services/grain-api.js
···
176
176
`;
177
177
178
178
const response = await this.#execute(gqlQuery, { query, first, after });
179
-
return this.#transformTimelineResponse(response);
179
+
return this.#transformSearchResponse(response);
180
+
}
181
+
182
+
#transformSearchResponse(response) {
183
+
const connection = response.data?.socialGrainGallery;
184
+
if (!connection) return { galleries: [], pageInfo: { hasNextPage: false } };
185
+
186
+
const galleries = connection.edges.map(edge => {
187
+
const node = edge.node;
188
+
const profile = node.socialGrainActorProfileByDid;
189
+
const items = node.socialGrainGalleryItemViaGallery?.edges || [];
190
+
191
+
const photos = items
192
+
.map(i => {
193
+
const photo = i.node.itemResolved;
194
+
if (!photo) return null;
195
+
return {
196
+
url: photo.photo?.url || '',
197
+
alt: photo.alt || '',
198
+
aspectRatio: photo.aspectRatio
199
+
? photo.aspectRatio.width / photo.aspectRatio.height
200
+
: 1
201
+
};
202
+
})
203
+
.filter(Boolean);
204
+
205
+
return {
206
+
uri: node.uri,
207
+
title: node.title,
208
+
description: node.description,
209
+
createdAt: node.createdAt,
210
+
handle: node.actorHandle,
211
+
displayName: profile?.displayName || '',
212
+
avatarUrl: profile?.avatar?.url || '',
213
+
photos,
214
+
favoriteCount: node.socialGrainFavoriteViaSubject?.totalCount || 0,
215
+
commentCount: node.socialGrainCommentViaSubject?.totalCount || 0,
216
+
viewerHasFavorited: false,
217
+
viewerFavoriteUri: null
218
+
};
219
+
}).filter(gallery => gallery.photos.length > 0);
220
+
221
+
// Cache each gallery record by URI (but don't update timeline query cache)
222
+
galleries.forEach(gallery => {
223
+
recordCache.set(gallery.uri, gallery);
224
+
});
225
+
226
+
return {
227
+
galleries,
228
+
pageInfo: connection.pageInfo
229
+
};
180
230
}
181
231
182
232
async searchProfiles(query, { first = 20, after = null } = {}) {
···
560
610
actorHandle
561
611
title
562
612
description
613
+
facets
563
614
createdAt
564
615
socialGrainActorProfileByDid {
565
616
displayName
···
592
643
node {
593
644
uri
594
645
text
646
+
facets
595
647
createdAt
596
648
actorHandle
597
649
replyTo
···
654
706
return {
655
707
uri: node.uri,
656
708
text: node.text,
709
+
facets: node.facets || [],
657
710
createdAt: node.createdAt,
658
711
handle: node.actorHandle,
659
712
displayName: commentProfile?.displayName || '',
···
668
721
uri: galleryNode.uri,
669
722
title: galleryNode.title,
670
723
description: galleryNode.description,
724
+
facets: galleryNode.facets || [],
671
725
createdAt: galleryNode.createdAt,
672
726
handle: galleryNode.actorHandle,
673
727
displayName: profile?.displayName || '',
···
988
1042
node {
989
1043
uri
990
1044
text
1045
+
facets
991
1046
createdAt
992
1047
actorHandle
993
1048
replyTo
···
1028
1083
return {
1029
1084
uri: node.uri,
1030
1085
text: node.text,
1086
+
facets: node.facets || [],
1031
1087
createdAt: node.createdAt,
1032
1088
handle: node.actorHandle,
1033
1089
displayName: profile?.displayName || '',
···
1042
1098
comments,
1043
1099
pageInfo: connection.pageInfo || { hasNextPage: false, endCursor: null },
1044
1100
totalCount: connection.totalCount || 0
1101
+
};
1102
+
}
1103
+
1104
+
async resolveHandle(handle) {
1105
+
const query = `
1106
+
query ResolveHandle($handle: String!) {
1107
+
socialGrainActorProfile(first: 1, where: { actorHandle: { eq: $handle } }) {
1108
+
edges {
1109
+
node { did }
1110
+
}
1111
+
}
1112
+
}
1113
+
`;
1114
+
1115
+
const response = await this.#execute(query, { handle });
1116
+
const did = response.data?.socialGrainActorProfile?.edges?.[0]?.node?.did;
1117
+
1118
+
if (!did) {
1119
+
throw new Error(`Handle not found: ${handle}`);
1120
+
}
1121
+
1122
+
return did;
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
1045
1169
};
1046
1170
}
1047
1171
}
+47
src/services/mutations.js
+47
src/services/mutations.js
···
1
1
import { auth } from './auth.js';
2
2
import { recordCache } from './record-cache.js';
3
+
import { parseTextToFacets } from '../lib/richtext.js';
4
+
import { grainApi } from './grain-api.js';
3
5
4
6
class MutationsService {
5
7
async createFavorite(galleryUri) {
···
102
104
103
105
async createComment(galleryUri, text, replyToUri = null, focusUri = null) {
104
106
const client = auth.getClient();
107
+
108
+
// Parse text for facets with handle resolution
109
+
const resolveHandle = async (handle) => grainApi.resolveHandle(handle);
110
+
const { facets } = await parseTextToFacets(text, resolveHandle);
111
+
105
112
const input = {
106
113
subject: galleryUri,
107
114
text,
108
115
createdAt: new Date().toISOString()
109
116
};
117
+
118
+
// Only include facets if we found any
119
+
if (facets && facets.length > 0) {
120
+
input.facets = facets;
121
+
}
110
122
111
123
if (replyToUri) {
112
124
input.replyTo = replyToUri;
···
185
197
186
198
// Refresh user data
187
199
await auth.refreshUser();
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;
188
235
}
189
236
}
190
237
+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
+
}