WIP PWA for Grain
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add create gallery page

+328
+328
src/components/pages/grain-create-gallery.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 '../atoms/grain-icon.js'; 6 + 7 + const UPLOAD_BLOB_MUTATION = ` 8 + mutation UploadBlob($data: String!, $mimeType: String!) { 9 + uploadBlob(data: $data, mimeType: $mimeType) { 10 + ref 11 + mimeType 12 + size 13 + } 14 + } 15 + `; 16 + 17 + const CREATE_PHOTO_MUTATION = ` 18 + mutation CreatePhoto($input: SocialGrainPhotoInput!) { 19 + createSocialGrainPhoto(input: $input) { 20 + uri 21 + } 22 + } 23 + `; 24 + 25 + const CREATE_GALLERY_MUTATION = ` 26 + mutation CreateGallery($input: SocialGrainGalleryInput!) { 27 + createSocialGrainGallery(input: $input) { 28 + uri 29 + } 30 + } 31 + `; 32 + 33 + const CREATE_GALLERY_ITEM_MUTATION = ` 34 + mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) { 35 + createSocialGrainGalleryItem(input: $input) { 36 + uri 37 + } 38 + } 39 + `; 40 + 41 + export class GrainCreateGallery extends LitElement { 42 + static properties = { 43 + _photos: { state: true }, 44 + _title: { state: true }, 45 + _description: { state: true }, 46 + _posting: { state: true }, 47 + _error: { state: true } 48 + }; 49 + 50 + static styles = css` 51 + :host { 52 + display: block; 53 + min-height: 100vh; 54 + min-height: 100dvh; 55 + } 56 + .header { 57 + display: flex; 58 + align-items: center; 59 + justify-content: space-between; 60 + padding: var(--space-sm); 61 + border-bottom: 1px solid var(--color-border); 62 + } 63 + .back-button { 64 + background: none; 65 + border: none; 66 + padding: 8px; 67 + margin-left: -8px; 68 + cursor: pointer; 69 + color: var(--color-text-primary); 70 + } 71 + .post-button { 72 + background: var(--color-accent, #0066cc); 73 + color: white; 74 + border: none; 75 + padding: 8px 16px; 76 + border-radius: 6px; 77 + font-weight: var(--font-weight-semibold); 78 + cursor: pointer; 79 + } 80 + .post-button:disabled { 81 + opacity: 0.5; 82 + cursor: not-allowed; 83 + } 84 + .photo-strip { 85 + display: flex; 86 + gap: var(--space-xs); 87 + padding: var(--space-sm); 88 + overflow-x: auto; 89 + border-bottom: 1px solid var(--color-border); 90 + } 91 + .photo-thumb { 92 + position: relative; 93 + flex-shrink: 0; 94 + } 95 + .photo-thumb img { 96 + width: 80px; 97 + height: 80px; 98 + object-fit: cover; 99 + border-radius: 4px; 100 + } 101 + .remove-photo { 102 + position: absolute; 103 + top: -6px; 104 + right: -6px; 105 + width: 20px; 106 + height: 20px; 107 + border-radius: 50%; 108 + background: var(--color-text-primary); 109 + color: var(--color-bg-primary); 110 + border: none; 111 + cursor: pointer; 112 + font-size: 12px; 113 + display: flex; 114 + align-items: center; 115 + justify-content: center; 116 + } 117 + .form { 118 + padding: var(--space-sm); 119 + } 120 + .form input, 121 + .form textarea { 122 + width: 100%; 123 + padding: var(--space-sm); 124 + border: 1px solid var(--color-border); 125 + border-radius: 6px; 126 + background: var(--color-bg-primary); 127 + color: var(--color-text-primary); 128 + font-size: var(--font-size-sm); 129 + font-family: inherit; 130 + margin-bottom: var(--space-sm); 131 + box-sizing: border-box; 132 + } 133 + .form textarea { 134 + min-height: 100px; 135 + resize: vertical; 136 + } 137 + .form input:focus, 138 + .form textarea:focus { 139 + outline: none; 140 + border-color: var(--color-accent, #0066cc); 141 + } 142 + .error { 143 + color: #ff4444; 144 + padding: var(--space-sm); 145 + text-align: center; 146 + } 147 + .char-count { 148 + font-size: var(--font-size-xs); 149 + color: var(--color-text-secondary); 150 + text-align: right; 151 + margin-top: -8px; 152 + margin-bottom: var(--space-sm); 153 + } 154 + `; 155 + 156 + constructor() { 157 + super(); 158 + this._photos = []; 159 + this._title = ''; 160 + this._description = ''; 161 + this._posting = false; 162 + this._error = null; 163 + } 164 + 165 + connectedCallback() { 166 + super.connectedCallback(); 167 + this._photos = draftGallery.getPhotos(); 168 + if (!this._photos.length) { 169 + router.push('/'); 170 + } 171 + } 172 + 173 + #handleBack() { 174 + if (confirm('Discard this gallery?')) { 175 + draftGallery.clear(); 176 + history.back(); 177 + } 178 + } 179 + 180 + #removePhoto(index) { 181 + this._photos = this._photos.filter((_, i) => i !== index); 182 + if (this._photos.length === 0) { 183 + draftGallery.clear(); 184 + router.push('/'); 185 + } 186 + } 187 + 188 + #handleTitleChange(e) { 189 + this._title = e.target.value.slice(0, 100); 190 + } 191 + 192 + #handleDescriptionChange(e) { 193 + this._description = e.target.value.slice(0, 1000); 194 + } 195 + 196 + get #canPost() { 197 + return this._title.trim().length > 0 && this._photos.length > 0 && !this._posting; 198 + } 199 + 200 + async #handlePost() { 201 + if (!this.#canPost) return; 202 + 203 + this._posting = true; 204 + this._error = null; 205 + 206 + try { 207 + const client = auth.getClient(); 208 + const now = new Date().toISOString(); 209 + 210 + // Upload photos and create photo records 211 + const photoUris = []; 212 + for (const photo of this._photos) { 213 + // Upload blob 214 + const base64Data = photo.dataUrl.split(',')[1]; 215 + const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, { 216 + data: base64Data, 217 + mimeType: 'image/jpeg' 218 + }); 219 + 220 + if (!uploadResult.uploadBlob) { 221 + throw new Error('Failed to upload image'); 222 + } 223 + 224 + // Create photo record 225 + const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, { 226 + input: { 227 + photo: { 228 + $type: 'blob', 229 + ref: { $link: uploadResult.uploadBlob.ref }, 230 + mimeType: uploadResult.uploadBlob.mimeType, 231 + size: uploadResult.uploadBlob.size 232 + }, 233 + aspectRatio: { 234 + width: photo.width, 235 + height: photo.height 236 + }, 237 + createdAt: now 238 + } 239 + }); 240 + 241 + photoUris.push(photoResult.createSocialGrainPhoto.uri); 242 + } 243 + 244 + // Create gallery record 245 + const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 246 + input: { 247 + title: this._title.trim(), 248 + ...(this._description.trim() && { description: this._description.trim() }), 249 + createdAt: now 250 + } 251 + }); 252 + 253 + const galleryUri = galleryResult.createSocialGrainGallery.uri; 254 + 255 + // Create gallery items linking photos to gallery 256 + for (let i = 0; i < photoUris.length; i++) { 257 + await client.mutate(CREATE_GALLERY_ITEM_MUTATION, { 258 + input: { 259 + gallery: galleryUri, 260 + item: photoUris[i], 261 + position: i, 262 + createdAt: now 263 + } 264 + }); 265 + } 266 + 267 + // Clear draft and navigate to new gallery 268 + draftGallery.clear(); 269 + const rkey = galleryUri.split('/').pop(); 270 + router.push(`/profile/${auth.user.handle}/gallery/${rkey}`); 271 + 272 + } catch (err) { 273 + console.error('Failed to create gallery:', err); 274 + this._error = err.message || 'Failed to create gallery. Please try again.'; 275 + } finally { 276 + this._posting = false; 277 + } 278 + } 279 + 280 + render() { 281 + return html` 282 + <div class="header"> 283 + <button class="back-button" @click=${this.#handleBack}> 284 + <grain-icon name="back" size="20"></grain-icon> 285 + </button> 286 + <button 287 + class="post-button" 288 + ?disabled=${!this.#canPost} 289 + @click=${this.#handlePost} 290 + > 291 + ${this._posting ? 'Posting...' : 'Post'} 292 + </button> 293 + </div> 294 + 295 + <div class="photo-strip"> 296 + ${this._photos.map((photo, i) => html` 297 + <div class="photo-thumb"> 298 + <img src=${photo.dataUrl} alt="Photo ${i + 1}"> 299 + <button class="remove-photo" @click=${() => this.#removePhoto(i)}>x</button> 300 + </div> 301 + `)} 302 + </div> 303 + 304 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 305 + 306 + <div class="form"> 307 + <input 308 + type="text" 309 + placeholder="Add a title..." 310 + .value=${this._title} 311 + @input=${this.#handleTitleChange} 312 + maxlength="100" 313 + > 314 + <div class="char-count">${this._title.length}/100</div> 315 + 316 + <textarea 317 + placeholder="Add a description (optional)..." 318 + .value=${this._description} 319 + @input=${this.#handleDescriptionChange} 320 + maxlength="1000" 321 + ></textarea> 322 + <div class="char-count">${this._description.length}/1000</div> 323 + </div> 324 + `; 325 + } 326 + } 327 + 328 + customElements.define('grain-create-gallery', GrainCreateGallery);