WIP PWA for Grain
0
fork

Configure Feed

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

feat: add gallery reporting with app-level dialog system

- Add report dialog for users to report galleries they don't own
- Implement app-level dialog registry system for proper overlay positioning
- Create grain-close-button atom for consistent close buttons
- Add action menu to gallery cards and detail page
- Improve dialog accessibility (Escape key, focus management)
- Use CSS variables consistently across dialog components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+2093 -62
+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 |
+41
src/components/atoms/grain-close-button.js
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import './grain-icon.js'; 3 + 4 + export class GrainCloseButton extends LitElement { 5 + static styles = css` 6 + :host { 7 + display: inline-flex; 8 + } 9 + button { 10 + display: flex; 11 + align-items: center; 12 + justify-content: center; 13 + background: none; 14 + border: none; 15 + padding: var(--space-xs); 16 + cursor: pointer; 17 + color: var(--color-text-secondary); 18 + touch-action: manipulation; 19 + } 20 + button:hover { 21 + color: var(--color-text-primary); 22 + } 23 + `; 24 + 25 + #handleClick() { 26 + this.dispatchEvent(new CustomEvent('close', { 27 + bubbles: true, 28 + composed: true 29 + })); 30 + } 31 + 32 + render() { 33 + return html` 34 + <button type="button" @click=${this.#handleClick} aria-label="Close"> 35 + <grain-icon name="close" size="20"></grain-icon> 36 + </button> 37 + `; 38 + } 39 + } 40 + 41 + customElements.define('grain-close-button', GrainCloseButton);
+3 -16
src/components/molecules/grain-login-dialog.js
··· 1 1 import { LitElement, html, css } from 'lit'; 2 - import '../atoms/grain-icon.js'; 2 + import '../atoms/grain-close-button.js'; 3 3 4 4 export class GrainLoginDialog extends LitElement { 5 5 static properties = { ··· 32 32 width: 90%; 33 33 max-width: 320px; 34 34 } 35 - .close-button { 35 + grain-close-button { 36 36 position: absolute; 37 37 top: var(--space-sm); 38 38 right: var(--space-sm); 39 - background: none; 40 - border: none; 41 - padding: var(--space-xs); 42 - cursor: pointer; 43 - color: var(--color-text-secondary); 44 - display: flex; 45 - align-items: center; 46 - justify-content: center; 47 - } 48 - .close-button:hover { 49 - color: var(--color-text-primary); 50 39 } 51 40 h2 { 52 41 margin: 0 0 var(--space-md); ··· 150 139 return html` 151 140 <div class="overlay" @click=${this.#handleOverlayClick}> 152 141 <form class="dialog" @submit=${this.#handleSubmit}> 153 - <button type="button" class="close-button" @click=${() => this.close()}> 154 - <grain-icon name="close" size="20"></grain-icon> 155 - </button> 142 + <grain-close-button @close=${() => this.close()}></grain-close-button> 156 143 <h2>Login with AT Protocol</h2> 157 144 <qs-actor-autocomplete 158 145 name="handle"
+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) {
+3 -12
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 = { ··· 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 - touch-action: manipulation; 90 - -webkit-tap-highlight-color: transparent; 91 84 } 92 85 .comments-list { 93 86 flex: 1; ··· 351 344 <div class="sheet"> 352 345 <div class="header"> 353 346 <h2>Comments</h2> 354 - <button class="close-button" @click=${this.#handleClose}> 355 - <grain-icon name="close" size="20"></grain-icon> 356 - </button> 347 + <grain-close-button @close=${this.#handleClose}></grain-close-button> 357 348 </div> 358 349 359 350 <div class="comments-list">
+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);
+78
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'; ··· 22 25 import '../organisms/grain-bottom-nav.js'; 23 26 24 27 export class GrainApp extends LitElement { 28 + static properties = { 29 + _dialogType: { state: true }, 30 + _dialogProps: { state: true } 31 + }; 32 + 25 33 static styles = css` 26 34 :host { 27 35 display: block; ··· 49 57 } 50 58 `; 51 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 + 52 128 firstUpdated() { 53 129 const outlet = this.shadowRoot.getElementById('outlet'); 54 130 ··· 78 154 <grain-header></grain-header> 79 155 <div id="outlet"></div> 80 156 <grain-bottom-nav></grain-bottom-nav> 157 + ${this.#renderDialog()} 158 + <grain-toast></grain-toast> 81 159 `; 82 160 } 83 161 }
+77 -1
src/components/pages/grain-timeline.js
··· 22 22 _commentGalleryUri: { state: true }, 23 23 _focusPhotoUri: { state: true }, 24 24 _focusPhotoUrl: { state: true }, 25 - _showScrollTop: { state: true } 25 + _showScrollTop: { state: true }, 26 + _pendingGallery: { state: true } 26 27 }; 27 28 28 29 static styles = css` ··· 62 63 this._focusPhotoUri = null; 63 64 this._focusPhotoUrl = null; 64 65 this._showScrollTop = false; 66 + this._pendingGallery = null; 65 67 66 68 // Check cache synchronously to avoid flash 67 69 this.#initFromCache(); ··· 94 96 this.#boundHandleScroll = this.#handleScroll.bind(this); 95 97 this.#scrollContainer = this.#findScrollContainer(); 96 98 (this.#scrollContainer || window).addEventListener('scroll', this.#boundHandleScroll, { passive: true }); 99 + document.addEventListener('dialog-action', this.#handleDialogAction); 97 100 } 98 101 99 102 #findScrollContainer() { ··· 117 120 if (this.#boundHandleScroll) { 118 121 (this.#scrollContainer || window).removeEventListener('scroll', this.#boundHandleScroll); 119 122 } 123 + document.removeEventListener('dialog-action', this.#handleDialogAction); 120 124 } 121 125 122 126 firstUpdated() { ··· 219 223 this._focusPhotoUrl = null; 220 224 } 221 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 + 222 297 #handleScroll() { 223 298 const scrollTop = this.#scrollContainer ? this.#scrollContainer.scrollTop : window.scrollY; 224 299 this._showScrollTop = scrollTop > 150; ··· 246 321 ?refreshing=${this._refreshing} 247 322 @refresh=${this.#handleRefresh} 248 323 @comment-click=${this.#handleCommentClick} 324 + @open-gallery-menu=${this.#handleGalleryMenu} 249 325 > 250 326 ${this._error ? html` 251 327 <p class="error">${this._error}</p>
+15
src/services/mutations.js
··· 218 218 createdAt: new Date().toISOString() 219 219 }); 220 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; 235 + } 221 236 } 222 237 223 238 export const mutations = new MutationsService();