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 {
Post
+ ?disabled=${!this.#canProceed}
+ @click=${this.#handleNext}
+ >Next
@@ -297,8 +178,6 @@ export class GrainCreateGallery extends LitElement {
`)}
- ${this._error ? html`${this._error}
` : ''}
-