WIP PWA for Grain
0
fork

Configure Feed

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

feat: improve notifications layout and styling

- Use grain-avatar component with fallback support
- Match explore page header height and sticky behavior
- Bsky-style layout with avatar left, content right
- Center-align follow-only notifications
- Reduce horizontal padding to match timeline
- Add week-based relative timestamps

+67 -35
+67 -35
src/components/pages/grain-notifications.js
··· 4 4 import { router } from '../../router.js'; 5 5 import '../templates/grain-feed-layout.js'; 6 6 import '../atoms/grain-spinner.js'; 7 + import '../atoms/grain-avatar.js'; 7 8 8 9 export class GrainNotifications extends LitElement { 9 10 static properties = { ··· 22 23 .header { 23 24 font-size: 1.25rem; 24 25 font-weight: 600; 25 - padding: var(--space-md) var(--space-lg); 26 + padding: var(--space-sm); 26 27 border-bottom: 1px solid var(--color-border); 28 + position: sticky; 29 + top: 48px; 30 + background: var(--color-bg-primary); 31 + z-index: 10; 27 32 } 28 33 .error { 29 34 padding: var(--space-lg); ··· 47 52 } 48 53 .notification-item { 49 54 display: flex; 50 - flex-direction: column; 51 55 gap: var(--space-sm); 52 - padding: var(--space-md) var(--space-lg); 56 + padding: var(--space-md) var(--space-sm); 53 57 border-bottom: 1px solid var(--color-border); 54 58 } 59 + .notification-item.follow-only { 60 + align-items: center; 61 + } 62 + .avatar-link { 63 + flex-shrink: 0; 64 + } 65 + .avatar-link grain-avatar { 66 + --avatar-size-md: 40px; 67 + } 68 + .notification-content { 69 + flex: 1; 70 + min-width: 0; 71 + } 55 72 .notification-header { 56 73 display: flex; 57 74 flex-wrap: wrap; 58 - align-items: center; 75 + align-items: baseline; 59 76 gap: var(--space-xs); 77 + line-height: 1.3; 60 78 } 61 - .author-link { 62 - display: flex; 63 - align-items: center; 64 - gap: var(--space-sm); 79 + .author-name { 80 + font-weight: 600; 65 81 text-decoration: none; 66 82 color: inherit; 67 83 } 68 - .author-link:hover { 84 + .author-name:hover { 69 85 text-decoration: underline; 70 86 } 71 - .avatar { 72 - width: 32px; 73 - height: 32px; 74 - border-radius: 50%; 75 - object-fit: cover; 76 - background: var(--color-bg-secondary); 77 - } 78 - .author-name { 79 - font-weight: 600; 80 - } 81 87 .action-text { 82 88 color: var(--color-text-secondary); 83 89 } ··· 86 92 } 87 93 .context { 88 94 margin-top: var(--space-xs); 95 + color: var(--color-text-secondary); 96 + font-size: 0.9375rem; 97 + line-height: 1.4; 98 + } 99 + .preview-text { 100 + margin-bottom: var(--space-xs); 89 101 } 90 102 .comment-text { 103 + color: var(--color-text-primary); 91 104 margin-bottom: var(--space-sm); 92 105 } 93 106 .reply-quote { ··· 102 115 height: 100px; 103 116 border-radius: var(--radius-sm); 104 117 object-fit: cover; 118 + margin-top: var(--space-xs); 105 119 } 106 120 .thumbnail-link { 107 121 display: block; ··· 217 231 const diffMin = Math.floor(diffSec / 60); 218 232 const diffHour = Math.floor(diffMin / 60); 219 233 const diffDay = Math.floor(diffHour / 24); 234 + const diffWeek = Math.floor(diffDay / 7); 220 235 221 236 if (diffSec < 60) return 'now'; 222 237 if (diffMin < 60) return `${diffMin}m`; 223 238 if (diffHour < 24) return `${diffHour}h`; 224 - if (diffDay < 7) return `${diffDay}d`; 239 + if (diffDay <= 6) return `${diffDay}d`; 240 + if (diffWeek < 52) return `${diffWeek}w`; 225 241 return date.toLocaleDateString(); 226 242 } 227 243 ··· 246 262 #renderNotification(n) { 247 263 const actionText = this.#getActionText(n.reason); 248 264 const time = this.#formatRelativeTime(n.createdAt); 265 + const itemClass = n.reason === 'follow' ? 'notification-item follow-only' : 'notification-item'; 249 266 250 267 return html` 251 - <li class="notification-item"> 252 - <div class="notification-header"> 253 - <a href="/profile/${n.author.handle}" class="author-link" @click=${this.#handleLink}> 254 - <img 255 - class="avatar" 256 - src=${n.author.avatarUrl || '/default-avatar.png'} 257 - alt="" 258 - loading="lazy" 259 - /> 260 - <span class="author-name">${n.author.displayName || n.author.handle}</span> 261 - </a> 262 - <span class="action-text">${actionText}</span> 263 - <span class="time">· ${time}</span> 268 + <li class=${itemClass}> 269 + <a href="/profile/${n.author.handle}" class="avatar-link" @click=${this.#handleLink}> 270 + <grain-avatar 271 + src=${n.author.avatarUrl || ''} 272 + size="md" 273 + ></grain-avatar> 274 + </a> 275 + <div class="notification-content"> 276 + <div class="notification-header"> 277 + <a href="/profile/${n.author.handle}" class="author-name" @click=${this.#handleLink}>${n.author.displayName || n.author.handle}</a> 278 + <span class="action-text">${actionText}</span> 279 + <span class="time">· ${time}</span> 280 + </div> 281 + ${this.#renderContext(n)} 264 282 </div> 265 - ${this.#renderContext(n)} 266 283 </li> 267 284 `; 268 285 } ··· 270 287 #renderContext(n) { 271 288 switch (n.reason) { 272 289 case 'gallery-favorite': 273 - return this.#renderGalleryThumbnail(n.gallery); 290 + return this.#renderGalleryPreview(n.gallery); 274 291 275 292 case 'gallery-comment': 276 293 case 'gallery-comment-mention': ··· 312 329 default: 313 330 return ''; 314 331 } 332 + } 333 + 334 + #renderGalleryPreview(gallery) { 335 + if (!gallery) return ''; 336 + const href = `/profile/${gallery.handle}/gallery/${this.#extractRkey(gallery.uri)}`; 337 + return html` 338 + <div class="context"> 339 + ${gallery.title ? html`<div class="preview-text">${gallery.title}</div>` : ''} 340 + ${gallery.thumbnailUrl ? html` 341 + <a href=${href} class="thumbnail-link" @click=${this.#handleLink}> 342 + <img class="thumbnail" src=${gallery.thumbnailUrl} alt=${gallery.title || ''} loading="lazy" /> 343 + </a> 344 + ` : ''} 345 + </div> 346 + `; 315 347 } 316 348 317 349 #renderGalleryThumbnail(gallery) {