+138
src/components/atoms/grain-alt-badge.js
+138
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
+
_showOverlay: { state: true }
7
+
};
8
+
9
+
static styles = css`
10
+
:host {
11
+
position: absolute;
12
+
bottom: 8px;
13
+
right: 8px;
14
+
z-index: 2;
15
+
}
16
+
.badge {
17
+
background: rgba(0, 0, 0, 0.7);
18
+
color: white;
19
+
font-size: 10px;
20
+
font-weight: 600;
21
+
padding: 2px 4px;
22
+
border-radius: 4px;
23
+
cursor: pointer;
24
+
user-select: none;
25
+
border: none;
26
+
font-family: inherit;
27
+
}
28
+
.badge:hover {
29
+
background: rgba(0, 0, 0, 0.85);
30
+
}
31
+
.badge:focus {
32
+
outline: 2px solid white;
33
+
outline-offset: 1px;
34
+
}
35
+
.overlay {
36
+
position: absolute;
37
+
bottom: 8px;
38
+
right: 8px;
39
+
left: -8px;
40
+
top: -8px;
41
+
transform: translate(-100%, -100%);
42
+
transform: none;
43
+
bottom: 0;
44
+
right: 0;
45
+
left: 0;
46
+
top: 0;
47
+
position: fixed;
48
+
background: rgba(0, 0, 0, 0.75);
49
+
color: white;
50
+
padding: 16px;
51
+
font-size: 14px;
52
+
line-height: 1.5;
53
+
overflow-y: auto;
54
+
display: flex;
55
+
align-items: center;
56
+
justify-content: center;
57
+
text-align: center;
58
+
box-sizing: border-box;
59
+
}
60
+
`;
61
+
62
+
#scrollHandler = null;
63
+
#carousel = null;
64
+
65
+
constructor() {
66
+
super();
67
+
this.alt = '';
68
+
this._showOverlay = false;
69
+
}
70
+
71
+
disconnectedCallback() {
72
+
super.disconnectedCallback();
73
+
this.#removeScrollListener();
74
+
}
75
+
76
+
#handleClick(e) {
77
+
e.stopPropagation();
78
+
this._showOverlay = !this._showOverlay;
79
+
}
80
+
81
+
#handleOverlayClick(e) {
82
+
e.stopPropagation();
83
+
this._showOverlay = false;
84
+
}
85
+
86
+
#removeScrollListener() {
87
+
if (this.#scrollHandler && this.#carousel) {
88
+
this.#carousel.removeEventListener('scroll', this.#scrollHandler);
89
+
this.#scrollHandler = null;
90
+
this.#carousel = null;
91
+
}
92
+
}
93
+
94
+
updated(changedProperties) {
95
+
if (changedProperties.has('_showOverlay')) {
96
+
if (this._showOverlay) {
97
+
// Position overlay to cover the parent slide
98
+
const slide = this.closest('.slide');
99
+
if (slide) {
100
+
const rect = slide.getBoundingClientRect();
101
+
const overlay = this.shadowRoot.querySelector('.overlay');
102
+
if (overlay) {
103
+
overlay.style.top = `${rect.top}px`;
104
+
overlay.style.left = `${rect.left}px`;
105
+
overlay.style.width = `${rect.width}px`;
106
+
overlay.style.height = `${rect.height}px`;
107
+
}
108
+
109
+
// Listen for carousel scroll to dismiss overlay
110
+
this.#carousel = slide.parentElement;
111
+
if (this.#carousel) {
112
+
this.#scrollHandler = () => {
113
+
this._showOverlay = false;
114
+
};
115
+
this.#carousel.addEventListener('scroll', this.#scrollHandler, { passive: true });
116
+
}
117
+
}
118
+
} else {
119
+
this.#removeScrollListener();
120
+
}
121
+
}
122
+
}
123
+
124
+
render() {
125
+
if (!this.alt) return null;
126
+
127
+
return html`
128
+
<button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button>
129
+
${this._showOverlay ? html`
130
+
<div class="overlay" @click=${this.#handleOverlayClick}>
131
+
${this.alt}
132
+
</div>
133
+
` : ''}
134
+
`;
135
+
}
136
+
}
137
+
138
+
customElements.define('grain-alt-badge', GrainAltBadge);
+3
src/components/organisms/grain-image-carousel.js
+3
src/components/organisms/grain-image-carousel.js
···
1
import { LitElement, html, css } from 'lit';
2
import '../atoms/grain-image.js';
3
import '../atoms/grain-icon.js';
4
import '../molecules/grain-carousel-dots.js';
5
6
export class GrainImageCarousel extends LitElement {
···
28
.slide {
29
flex: 0 0 100%;
30
scroll-snap-align: start;
31
}
32
.slide.centered {
33
display: flex;
···
156
aspectRatio=${photo.aspectRatio || 1}
157
style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''}
158
></grain-image>
159
</div>
160
`)}
161
</div>
···
1
import { LitElement, html, css } from 'lit';
2
import '../atoms/grain-image.js';
3
import '../atoms/grain-icon.js';
4
+
import '../atoms/grain-alt-badge.js';
5
import '../molecules/grain-carousel-dots.js';
6
7
export class GrainImageCarousel extends LitElement {
···
29
.slide {
30
flex: 0 0 100%;
31
scroll-snap-align: start;
32
+
position: relative;
33
}
34
.slide.centered {
35
display: flex;
···
158
aspectRatio=${photo.aspectRatio || 1}
159
style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''}
160
></grain-image>
161
+
${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''}
162
</div>
163
`)}
164
</div>
+2
src/components/pages/grain-app.js
+2
src/components/pages/grain-app.js
···
10
import './grain-settings.js';
11
import './grain-edit-profile.js';
12
import './grain-create-gallery.js';
13
import './grain-explore.js';
14
import './grain-notifications.js';
15
import './grain-terms.js';
···
61
.register('/legal/privacy', 'grain-privacy')
62
.register('/legal/copyright', 'grain-copyright')
63
.register('/create', 'grain-create-gallery')
64
.register('/explore', 'grain-explore')
65
.register('/notifications', 'grain-notifications')
66
.register('*', 'grain-timeline')
···
10
import './grain-settings.js';
11
import './grain-edit-profile.js';
12
import './grain-create-gallery.js';
13
+
import './grain-image-descriptions.js';
14
import './grain-explore.js';
15
import './grain-notifications.js';
16
import './grain-terms.js';
···
62
.register('/legal/privacy', 'grain-privacy')
63
.register('/legal/copyright', 'grain-copyright')
64
.register('/create', 'grain-create-gallery')
65
+
.register('/create/descriptions', 'grain-image-descriptions')
66
.register('/explore', 'grain-explore')
67
.register('/notifications', 'grain-notifications')
68
.register('*', 'grain-timeline')
+22
-143
src/components/pages/grain-create-gallery.js
+22
-143
src/components/pages/grain-create-gallery.js
···
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-input.js';
10
import '../atoms/grain-textarea.js';
11
import '../molecules/grain-form-field.js';
12
13
-
const UPLOAD_BLOB_MUTATION = `
14
-
mutation UploadBlob($data: String!, $mimeType: String!) {
15
-
uploadBlob(data: $data, mimeType: $mimeType) {
16
-
ref
17
-
mimeType
18
-
size
19
-
}
20
-
}
21
-
`;
22
-
23
-
const CREATE_PHOTO_MUTATION = `
24
-
mutation CreatePhoto($input: SocialGrainPhotoInput!) {
25
-
createSocialGrainPhoto(input: $input) {
26
-
uri
27
-
}
28
-
}
29
-
`;
30
-
31
-
const CREATE_GALLERY_MUTATION = `
32
-
mutation CreateGallery($input: SocialGrainGalleryInput!) {
33
-
createSocialGrainGallery(input: $input) {
34
-
uri
35
-
}
36
-
}
37
-
`;
38
-
39
-
const CREATE_GALLERY_ITEM_MUTATION = `
40
-
mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) {
41
-
createSocialGrainGalleryItem(input: $input) {
42
-
uri
43
-
}
44
-
}
45
-
`;
46
-
47
export class GrainCreateGallery extends LitElement {
48
static properties = {
49
_photos: { state: true },
50
_title: { state: true },
51
-
_description: { state: true },
52
-
_posting: { state: true },
53
-
_error: { state: true }
54
};
55
56
static styles = css`
···
122
.form {
123
padding: var(--space-sm);
124
}
125
-
.error {
126
-
color: #ff4444;
127
-
padding: var(--space-sm);
128
-
text-align: center;
129
-
}
130
`;
131
132
constructor() {
···
134
this._photos = [];
135
this._title = '';
136
this._description = '';
137
-
this._posting = false;
138
-
this._error = null;
139
}
140
141
connectedCallback() {
142
super.connectedCallback();
143
144
-
// Redirect to timeline if not authenticated
145
if (!auth.isAuthenticated) {
146
router.replace('/');
147
return;
148
}
149
150
this._photos = draftGallery.getPhotos();
151
if (!this._photos.length) {
152
router.push('/');
153
}
···
156
#handleBack() {
157
if (confirm('Discard this gallery?')) {
158
draftGallery.clear();
159
history.back();
160
}
161
}
162
163
#removePhoto(index) {
164
this._photos = this._photos.filter((_, i) => i !== index);
165
if (this._photos.length === 0) {
166
draftGallery.clear();
167
router.push('/');
168
}
169
}
···
176
this._description = e.detail.value.slice(0, 1000);
177
}
178
179
-
get #canPost() {
180
-
return this._title.trim().length > 0 && this._photos.length > 0 && !this._posting;
181
}
182
183
-
async #handlePost() {
184
-
if (!this.#canPost) return;
185
-
186
-
this._posting = true;
187
-
this._error = null;
188
189
-
try {
190
-
const client = auth.getClient();
191
-
const now = new Date().toISOString();
192
193
-
// Upload photos and create photo records
194
-
const photoUris = [];
195
-
for (const photo of this._photos) {
196
-
// Upload blob
197
-
const base64Data = photo.dataUrl.split(',')[1];
198
-
const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, {
199
-
data: base64Data,
200
-
mimeType: 'image/jpeg'
201
-
});
202
-
203
-
if (!uploadResult.uploadBlob) {
204
-
throw new Error('Failed to upload image');
205
-
}
206
-
207
-
// Create photo record
208
-
const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, {
209
-
input: {
210
-
photo: {
211
-
$type: 'blob',
212
-
ref: { $link: uploadResult.uploadBlob.ref },
213
-
mimeType: uploadResult.uploadBlob.mimeType,
214
-
size: uploadResult.uploadBlob.size
215
-
},
216
-
aspectRatio: {
217
-
width: photo.width,
218
-
height: photo.height
219
-
},
220
-
createdAt: now
221
-
}
222
-
});
223
-
224
-
photoUris.push(photoResult.createSocialGrainPhoto.uri);
225
-
}
226
-
227
-
// Parse description for facets
228
-
let facets = null;
229
-
if (this._description.trim()) {
230
-
const resolveHandle = async (handle) => grainApi.resolveHandle(handle);
231
-
const parsed = await parseTextToFacets(this._description.trim(), resolveHandle);
232
-
if (parsed.facets.length > 0) {
233
-
facets = parsed.facets;
234
-
}
235
-
}
236
-
237
-
// Create gallery record
238
-
const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, {
239
-
input: {
240
-
title: this._title.trim(),
241
-
...(this._description.trim() && { description: this._description.trim() }),
242
-
...(facets && { facets }),
243
-
createdAt: now
244
-
}
245
-
});
246
-
247
-
const galleryUri = galleryResult.createSocialGrainGallery.uri;
248
-
249
-
// Create gallery items linking photos to gallery
250
-
for (let i = 0; i < photoUris.length; i++) {
251
-
await client.mutate(CREATE_GALLERY_ITEM_MUTATION, {
252
-
input: {
253
-
gallery: galleryUri,
254
-
item: photoUris[i],
255
-
position: i,
256
-
createdAt: now
257
-
}
258
-
});
259
-
}
260
-
261
-
// Clear draft and navigate to new gallery
262
-
draftGallery.clear();
263
-
const rkey = galleryUri.split('/').pop();
264
-
router.push(`/profile/${auth.user.handle}/gallery/${rkey}`);
265
-
266
-
} catch (err) {
267
-
console.error('Failed to create gallery:', err);
268
-
this._error = err.message || 'Failed to create gallery. Please try again.';
269
-
} finally {
270
-
this._posting = false;
271
-
}
272
}
273
274
render() {
···
281
<span class="header-title">Create a gallery</span>
282
</div>
283
<grain-button
284
-
?disabled=${!this.#canPost}
285
-
?loading=${this._posting}
286
-
loadingText="Posting..."
287
-
@click=${this.#handlePost}
288
-
>Post</grain-button>
289
</div>
290
291
<div class="photo-strip">
···
296
</div>
297
`)}
298
</div>
299
-
300
-
${this._error ? html`<p class="error">${this._error}</p>` : ''}
301
302
<div class="form">
303
<grain-form-field .value=${this._title} .maxlength=${100}>
···
2
import { router } from '../../router.js';
3
import { auth } from '../../services/auth.js';
4
import { draftGallery } from '../../services/draft-gallery.js';
5
import '../atoms/grain-icon.js';
6
import '../atoms/grain-button.js';
7
import '../atoms/grain-input.js';
8
import '../atoms/grain-textarea.js';
9
import '../molecules/grain-form-field.js';
10
11
export class GrainCreateGallery extends LitElement {
12
static properties = {
13
_photos: { state: true },
14
_title: { state: true },
15
+
_description: { state: true }
16
};
17
18
static styles = css`
···
84
.form {
85
padding: var(--space-sm);
86
}
87
`;
88
89
constructor() {
···
91
this._photos = [];
92
this._title = '';
93
this._description = '';
94
}
95
96
connectedCallback() {
97
super.connectedCallback();
98
99
if (!auth.isAuthenticated) {
100
router.replace('/');
101
return;
102
}
103
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
+
110
if (!this._photos.length) {
111
router.push('/');
112
}
···
115
#handleBack() {
116
if (confirm('Discard this gallery?')) {
117
draftGallery.clear();
118
+
sessionStorage.removeItem('draft_title');
119
+
sessionStorage.removeItem('draft_description');
120
history.back();
121
}
122
}
123
124
#removePhoto(index) {
125
this._photos = this._photos.filter((_, i) => i !== index);
126
+
draftGallery.setPhotos(this._photos);
127
if (this._photos.length === 0) {
128
draftGallery.clear();
129
+
sessionStorage.removeItem('draft_title');
130
+
sessionStorage.removeItem('draft_description');
131
router.push('/');
132
}
133
}
···
140
this._description = e.detail.value.slice(0, 1000);
141
}
142
143
+
get #canProceed() {
144
+
return this._title.trim().length > 0 && this._photos.length > 0;
145
}
146
147
+
#handleNext() {
148
+
if (!this.#canProceed) return;
149
150
+
sessionStorage.setItem('draft_title', this._title);
151
+
sessionStorage.setItem('draft_description', this._description);
152
+
draftGallery.setPhotos(this._photos);
153
154
+
router.push('/create/descriptions');
155
}
156
157
render() {
···
164
<span class="header-title">Create a gallery</span>
165
</div>
166
<grain-button
167
+
?disabled=${!this.#canProceed}
168
+
@click=${this.#handleNext}
169
+
>Next</grain-button>
170
</div>
171
172
<div class="photo-strip">
···
177
</div>
178
`)}
179
</div>
180
181
<div class="form">
182
<grain-form-field .value=${this._title} .maxlength=${100}>
+289
src/components/pages/grain-image-descriptions.js
+289
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
+
this._photos = [...draftGallery.getPhotos()];
161
+
}
162
+
163
+
async #handlePost() {
164
+
if (this._posting) return;
165
+
166
+
this._posting = true;
167
+
this._error = null;
168
+
169
+
try {
170
+
const client = auth.getClient();
171
+
const now = new Date().toISOString();
172
+
173
+
const photoUris = [];
174
+
for (const photo of this._photos) {
175
+
const base64Data = photo.dataUrl.split(',')[1];
176
+
const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, {
177
+
data: base64Data,
178
+
mimeType: 'image/jpeg'
179
+
});
180
+
181
+
if (!uploadResult.uploadBlob) {
182
+
throw new Error('Failed to upload image');
183
+
}
184
+
185
+
const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, {
186
+
input: {
187
+
photo: {
188
+
$type: 'blob',
189
+
ref: { $link: uploadResult.uploadBlob.ref },
190
+
mimeType: uploadResult.uploadBlob.mimeType,
191
+
size: uploadResult.uploadBlob.size
192
+
},
193
+
aspectRatio: {
194
+
width: photo.width,
195
+
height: photo.height
196
+
},
197
+
...(photo.alt && { alt: photo.alt }),
198
+
createdAt: now
199
+
}
200
+
});
201
+
202
+
photoUris.push(photoResult.createSocialGrainPhoto.uri);
203
+
}
204
+
205
+
let facets = null;
206
+
if (this._description.trim()) {
207
+
const resolveHandle = async (handle) => grainApi.resolveHandle(handle);
208
+
const parsed = await parseTextToFacets(this._description.trim(), resolveHandle);
209
+
if (parsed.facets.length > 0) {
210
+
facets = parsed.facets;
211
+
}
212
+
}
213
+
214
+
const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, {
215
+
input: {
216
+
title: this._title.trim(),
217
+
...(this._description.trim() && { description: this._description.trim() }),
218
+
...(facets && { facets }),
219
+
createdAt: now
220
+
}
221
+
});
222
+
223
+
const galleryUri = galleryResult.createSocialGrainGallery.uri;
224
+
225
+
for (let i = 0; i < photoUris.length; i++) {
226
+
await client.mutate(CREATE_GALLERY_ITEM_MUTATION, {
227
+
input: {
228
+
gallery: galleryUri,
229
+
item: photoUris[i],
230
+
position: i,
231
+
createdAt: now
232
+
}
233
+
});
234
+
}
235
+
236
+
draftGallery.clear();
237
+
sessionStorage.removeItem('draft_title');
238
+
sessionStorage.removeItem('draft_description');
239
+
const rkey = galleryUri.split('/').pop();
240
+
router.push(`/profile/${auth.user.handle}/gallery/${rkey}`);
241
+
242
+
} catch (err) {
243
+
console.error('Failed to create gallery:', err);
244
+
this._error = err.message || 'Failed to create gallery. Please try again.';
245
+
} finally {
246
+
this._posting = false;
247
+
}
248
+
}
249
+
250
+
render() {
251
+
return html`
252
+
<div class="header">
253
+
<div class="header-left">
254
+
<button class="back-button" @click=${this.#handleBack}>
255
+
<grain-icon name="back" size="20"></grain-icon>
256
+
</button>
257
+
<span class="header-title">Add image descriptions</span>
258
+
</div>
259
+
<grain-button
260
+
?loading=${this._posting}
261
+
loadingText="Posting..."
262
+
@click=${this.#handlePost}
263
+
>Post</grain-button>
264
+
</div>
265
+
266
+
${this._error ? html`<p class="error">${this._error}</p>` : ''}
267
+
268
+
<p class="info">Alt text describes images for blind and low-vision users, and helps give context to everyone.</p>
269
+
270
+
<div class="photo-list">
271
+
${this._photos.map((photo, i) => html`
272
+
<div class="photo-row">
273
+
<img class="photo-thumb" src=${photo.dataUrl} alt="Photo ${i + 1}">
274
+
<div class="alt-input">
275
+
<grain-textarea
276
+
placeholder="Alt text"
277
+
.value=${photo.alt || ''}
278
+
.maxlength=${1000}
279
+
@input=${(e) => this.#handleAltChange(i, e)}
280
+
></grain-textarea>
281
+
</div>
282
+
</div>
283
+
`)}
284
+
</div>
285
+
`;
286
+
}
287
+
}
288
+
289
+
customElements.define('grain-image-descriptions', GrainImageDescriptions);
+8
-1
src/services/draft-gallery.js
+8
-1
src/services/draft-gallery.js
···
2
#photos = [];
3
4
setPhotos(photos) {
5
+
// Ensure each photo has an alt property
6
+
this.#photos = photos.map(p => ({ ...p, alt: p.alt || '' }));
7
}
8
9
getPhotos() {
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
+
}
17
}
18
19
clear() {