diff --git a/src/components/atoms/grain-alt-badge.js b/src/components/atoms/grain-alt-badge.js new file mode 100644 index 0000000..f5289a2 --- /dev/null +++ b/src/components/atoms/grain-alt-badge.js @@ -0,0 +1,138 @@ +import { LitElement, html, css } from 'lit'; + +export class GrainAltBadge extends LitElement { + static properties = { + alt: { type: String }, + _showOverlay: { state: true } + }; + + static styles = css` + :host { + position: absolute; + bottom: 8px; + right: 8px; + z-index: 2; + } + .badge { + background: rgba(0, 0, 0, 0.7); + color: white; + font-size: 10px; + font-weight: 600; + padding: 2px 4px; + border-radius: 4px; + cursor: pointer; + user-select: none; + border: none; + font-family: inherit; + } + .badge:hover { + background: rgba(0, 0, 0, 0.85); + } + .badge:focus { + outline: 2px solid white; + outline-offset: 1px; + } + .overlay { + position: absolute; + bottom: 8px; + right: 8px; + left: -8px; + top: -8px; + transform: translate(-100%, -100%); + transform: none; + bottom: 0; + right: 0; + left: 0; + top: 0; + position: fixed; + background: rgba(0, 0, 0, 0.75); + color: white; + padding: 16px; + font-size: 14px; + line-height: 1.5; + overflow-y: auto; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + box-sizing: border-box; + } + `; + + #scrollHandler = null; + #carousel = null; + + constructor() { + super(); + this.alt = ''; + this._showOverlay = false; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#removeScrollListener(); + } + + #handleClick(e) { + e.stopPropagation(); + this._showOverlay = !this._showOverlay; + } + + #handleOverlayClick(e) { + e.stopPropagation(); + this._showOverlay = false; + } + + #removeScrollListener() { + if (this.#scrollHandler && this.#carousel) { + this.#carousel.removeEventListener('scroll', this.#scrollHandler); + this.#scrollHandler = null; + this.#carousel = null; + } + } + + updated(changedProperties) { + if (changedProperties.has('_showOverlay')) { + if (this._showOverlay) { + // Position overlay to cover the parent slide + const slide = this.closest('.slide'); + if (slide) { + const rect = slide.getBoundingClientRect(); + const overlay = this.shadowRoot.querySelector('.overlay'); + if (overlay) { + overlay.style.top = `${rect.top}px`; + overlay.style.left = `${rect.left}px`; + overlay.style.width = `${rect.width}px`; + overlay.style.height = `${rect.height}px`; + } + + // Listen for carousel scroll to dismiss overlay + this.#carousel = slide.parentElement; + if (this.#carousel) { + this.#scrollHandler = () => { + this._showOverlay = false; + }; + this.#carousel.addEventListener('scroll', this.#scrollHandler, { passive: true }); + } + } + } else { + this.#removeScrollListener(); + } + } + } + + render() { + if (!this.alt) return null; + + return html` + + ${this._showOverlay ? html` +
+ ${this.alt} +
+ ` : ''} + `; + } +} + +customElements.define('grain-alt-badge', GrainAltBadge); diff --git a/src/components/organisms/grain-image-carousel.js b/src/components/organisms/grain-image-carousel.js index b7b9419..6dc457e 100644 --- a/src/components/organisms/grain-image-carousel.js +++ b/src/components/organisms/grain-image-carousel.js @@ -1,6 +1,7 @@ import { LitElement, html, css } from 'lit'; import '../atoms/grain-image.js'; import '../atoms/grain-icon.js'; +import '../atoms/grain-alt-badge.js'; import '../molecules/grain-carousel-dots.js'; export class GrainImageCarousel extends LitElement { @@ -28,6 +29,7 @@ export class GrainImageCarousel extends LitElement { .slide { flex: 0 0 100%; scroll-snap-align: start; + position: relative; } .slide.centered { display: flex; @@ -156,6 +158,7 @@ export class GrainImageCarousel extends LitElement { aspectRatio=${photo.aspectRatio || 1} style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} > + ${photo.alt ? html`` : ''} `)} diff --git a/src/components/pages/grain-app.js b/src/components/pages/grain-app.js index c840a65..233f510 100644 --- a/src/components/pages/grain-app.js +++ b/src/components/pages/grain-app.js @@ -10,6 +10,7 @@ import './grain-gallery-detail.js'; import './grain-settings.js'; import './grain-edit-profile.js'; import './grain-create-gallery.js'; +import './grain-image-descriptions.js'; import './grain-explore.js'; import './grain-notifications.js'; import './grain-terms.js'; @@ -61,6 +62,7 @@ export class GrainApp extends LitElement { .register('/legal/privacy', 'grain-privacy') .register('/legal/copyright', 'grain-copyright') .register('/create', 'grain-create-gallery') + .register('/create/descriptions', 'grain-image-descriptions') .register('/explore', 'grain-explore') .register('/notifications', 'grain-notifications') .register('*', 'grain-timeline') diff --git a/src/components/pages/grain-create-gallery.js b/src/components/pages/grain-create-gallery.js index 5b62ff9..226bbd9 100644 --- a/src/components/pages/grain-create-gallery.js +++ b/src/components/pages/grain-create-gallery.js @@ -2,55 +2,17 @@ import { LitElement, html, css } from 'lit'; import { router } from '../../router.js'; import { auth } from '../../services/auth.js'; import { draftGallery } from '../../services/draft-gallery.js'; -import { parseTextToFacets } from '../../lib/richtext.js'; -import { grainApi } from '../../services/grain-api.js'; import '../atoms/grain-icon.js'; import '../atoms/grain-button.js'; import '../atoms/grain-input.js'; import '../atoms/grain-textarea.js'; import '../molecules/grain-form-field.js'; -const UPLOAD_BLOB_MUTATION = ` - mutation UploadBlob($data: String!, $mimeType: String!) { - uploadBlob(data: $data, mimeType: $mimeType) { - ref - mimeType - size - } - } -`; - -const CREATE_PHOTO_MUTATION = ` - mutation CreatePhoto($input: SocialGrainPhotoInput!) { - createSocialGrainPhoto(input: $input) { - uri - } - } -`; - -const CREATE_GALLERY_MUTATION = ` - mutation CreateGallery($input: SocialGrainGalleryInput!) { - createSocialGrainGallery(input: $input) { - uri - } - } -`; - -const CREATE_GALLERY_ITEM_MUTATION = ` - mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) { - createSocialGrainGalleryItem(input: $input) { - uri - } - } -`; - export class GrainCreateGallery extends LitElement { static properties = { _photos: { state: true }, _title: { state: true }, - _description: { state: true }, - _posting: { state: true }, - _error: { state: true } + _description: { state: true } }; static styles = css` @@ -122,11 +84,6 @@ export class GrainCreateGallery extends LitElement { .form { padding: var(--space-sm); } - .error { - color: #ff4444; - padding: var(--space-sm); - text-align: center; - } `; constructor() { @@ -134,20 +91,22 @@ export class GrainCreateGallery extends LitElement { this._photos = []; this._title = ''; this._description = ''; - this._posting = false; - this._error = null; } connectedCallback() { super.connectedCallback(); - // Redirect to timeline if not authenticated if (!auth.isAuthenticated) { router.replace('/'); return; } this._photos = draftGallery.getPhotos(); + + // Restore title/description if returning from descriptions page + this._title = sessionStorage.getItem('draft_title') || ''; + this._description = sessionStorage.getItem('draft_description') || ''; + if (!this._photos.length) { router.push('/'); } @@ -156,14 +115,19 @@ export class GrainCreateGallery extends LitElement { #handleBack() { if (confirm('Discard this gallery?')) { draftGallery.clear(); + sessionStorage.removeItem('draft_title'); + sessionStorage.removeItem('draft_description'); history.back(); } } #removePhoto(index) { this._photos = this._photos.filter((_, i) => i !== index); + draftGallery.setPhotos(this._photos); if (this._photos.length === 0) { draftGallery.clear(); + sessionStorage.removeItem('draft_title'); + sessionStorage.removeItem('draft_description'); router.push('/'); } } @@ -176,99 +140,18 @@ export class GrainCreateGallery extends LitElement { this._description = e.detail.value.slice(0, 1000); } - get #canPost() { - return this._title.trim().length > 0 && this._photos.length > 0 && !this._posting; + get #canProceed() { + return this._title.trim().length > 0 && this._photos.length > 0; } - async #handlePost() { - if (!this.#canPost) return; - - this._posting = true; - this._error = null; - - try { - const client = auth.getClient(); - const now = new Date().toISOString(); - - // Upload photos and create photo records - const photoUris = []; - for (const photo of this._photos) { - // Upload blob - const base64Data = photo.dataUrl.split(',')[1]; - const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, { - data: base64Data, - mimeType: 'image/jpeg' - }); - - if (!uploadResult.uploadBlob) { - throw new Error('Failed to upload image'); - } - - // Create photo record - const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, { - input: { - photo: { - $type: 'blob', - ref: { $link: uploadResult.uploadBlob.ref }, - mimeType: uploadResult.uploadBlob.mimeType, - size: uploadResult.uploadBlob.size - }, - aspectRatio: { - width: photo.width, - height: photo.height - }, - createdAt: now - } - }); - - photoUris.push(photoResult.createSocialGrainPhoto.uri); - } + #handleNext() { + if (!this.#canProceed) return; - // Parse description for facets - let facets = null; - if (this._description.trim()) { - const resolveHandle = async (handle) => grainApi.resolveHandle(handle); - const parsed = await parseTextToFacets(this._description.trim(), resolveHandle); - if (parsed.facets.length > 0) { - facets = parsed.facets; - } - } + sessionStorage.setItem('draft_title', this._title); + sessionStorage.setItem('draft_description', this._description); + draftGallery.setPhotos(this._photos); - // Create gallery record - const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { - input: { - title: this._title.trim(), - ...(this._description.trim() && { description: this._description.trim() }), - ...(facets && { facets }), - createdAt: now - } - }); - - const galleryUri = galleryResult.createSocialGrainGallery.uri; - - // Create gallery items linking photos to gallery - for (let i = 0; i < photoUris.length; i++) { - await client.mutate(CREATE_GALLERY_ITEM_MUTATION, { - input: { - gallery: galleryUri, - item: photoUris[i], - position: i, - createdAt: now - } - }); - } - - // Clear draft and navigate to new gallery - draftGallery.clear(); - const rkey = galleryUri.split('/').pop(); - router.push(`/profile/${auth.user.handle}/gallery/${rkey}`); - - } catch (err) { - console.error('Failed to create gallery:', err); - this._error = err.message || 'Failed to create gallery. Please try again.'; - } finally { - this._posting = false; - } + router.push('/create/descriptions'); } render() { @@ -281,11 +164,9 @@ export class GrainCreateGallery extends LitElement { Create a gallery Post + ?disabled=${!this.#canProceed} + @click=${this.#handleNext} + >Next
@@ -297,8 +178,6 @@ export class GrainCreateGallery extends LitElement { `)}
- ${this._error ? html`

${this._error}

` : ''} -
grainApi.resolveHandle(handle); + const parsed = await parseTextToFacets(this._description.trim(), resolveHandle); + if (parsed.facets.length > 0) { + facets = parsed.facets; + } + } + + const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { + input: { + title: this._title.trim(), + ...(this._description.trim() && { description: this._description.trim() }), + ...(facets && { facets }), + createdAt: now + } + }); + + const galleryUri = galleryResult.createSocialGrainGallery.uri; + + for (let i = 0; i < photoUris.length; i++) { + await client.mutate(CREATE_GALLERY_ITEM_MUTATION, { + input: { + gallery: galleryUri, + item: photoUris[i], + position: i, + createdAt: now + } + }); + } + + draftGallery.clear(); + sessionStorage.removeItem('draft_title'); + sessionStorage.removeItem('draft_description'); + const rkey = galleryUri.split('/').pop(); + router.push(`/profile/${auth.user.handle}/gallery/${rkey}`); + + } catch (err) { + console.error('Failed to create gallery:', err); + this._error = err.message || 'Failed to create gallery. Please try again.'; + } finally { + this._posting = false; + } + } + + render() { + return html` +
+
+ + Add image descriptions +
+ Post +
+ + ${this._error ? html`

${this._error}

` : ''} + +

Alt text describes images for blind and low-vision users, and helps give context to everyone.

+ +
+ ${this._photos.map((photo, i) => html` +
+ Photo ${i + 1} +
+ this.#handleAltChange(i, e)} + > +
+
+ `)} +
+ `; + } +} + +customElements.define('grain-image-descriptions', GrainImageDescriptions); diff --git a/src/services/draft-gallery.js b/src/services/draft-gallery.js index c99e883..7fbe283 100644 --- a/src/services/draft-gallery.js +++ b/src/services/draft-gallery.js @@ -2,13 +2,20 @@ class DraftGalleryService { #photos = []; setPhotos(photos) { - this.#photos = [...photos]; + // Ensure each photo has an alt property + this.#photos = photos.map(p => ({ ...p, alt: p.alt || '' })); } getPhotos() { return this.#photos; } + updatePhotoAlt(index, alt) { + if (index >= 0 && index < this.#photos.length) { + this.#photos[index] = { ...this.#photos[index], alt }; + } + } + clear() { this.#photos = []; }