grain.social is a photo sharing platform built on atproto.

add masonry layout to the gallery page

Changed files
+143 -50
static
+15 -13
main.tsx
··· 160 160 } 161 161 if (!gallery) return ctx.next(); 162 162 ctx.state.meta = getGalleryMeta(gallery); 163 - ctx.state.scripts = ["photo_dialog.js"]; 163 + ctx.state.scripts = ["photo_dialog.js", "masonry.js"]; 164 164 return ctx.render( 165 165 <GalleryPage favs={favs} gallery={gallery} currentUserDid={did} />, 166 166 ); ··· 374 374 ]; 375 375 return ctx.html( 376 376 <> 377 - <div hx-swap-oob="beforeend:#gallery-photo-grid"> 377 + <div hx-swap-oob="beforeend:#masonry-container"> 378 378 <PhotoButton 379 379 key={photo.cid} 380 380 photo={photoToView(photo.did, photo)} ··· 956 956 href="https://unpkg.com/@fortawesome/fontawesome-free@6.7.2/css/all.min.css" 957 957 preload 958 958 /> 959 - {scripts?.includes("photo_dialog.js") 960 - ? <script src="/static/photo_dialog.js" /> 961 - : null} 959 + {scripts?.map((file) => <script key={file} src={`/static/${file}`} />)} 962 960 </head> 963 961 <body class="h-full w-full dark:bg-zinc-950 dark:text-white"> 964 962 <Layout id="layout" class="dark:border-zinc-800"> ··· 1378 1376 > 1379 1377 <label htmlFor="file"> 1380 1378 <span class="sr-only">Upload avatar</span> 1381 - <div class="border rounded-full border-slate-900 w-16 h-16 mx-auto mb-2 relative my-2 cursor-pointer"> 1382 - <div class="absolute bottom-0 right-0 bg-slate-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 1379 + <div class="border rounded-full border-zinc-900 w-16 h-16 mx-auto mb-2 relative my-2 cursor-pointer"> 1380 + <div class="absolute bottom-0 right-0 bg-zinc-800 rounded-full w-5 h-5 flex items-center justify-center z-10"> 1383 1381 <i class="fa-solid fa-camera text-white text-xs"></i> 1384 1382 </div> 1385 1383 <div id="image-preview" class="w-full h-full"> ··· 1474 1472 : null} 1475 1473 </div> 1476 1474 <div 1477 - id="gallery-photo-grid" 1478 - class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4" 1475 + id="masonry-container" 1476 + class="h-0 overflow-hidden relative mx-auto w-full" 1477 + _="on load or htmx:afterSettle call computeMasonry()" 1479 1478 > 1480 1479 {gallery.items?.filter(isPhotoView)?.length 1481 1480 ? gallery?.items?.filter(isPhotoView)?.map((photo) => ( ··· 1507 1506 hx-trigger="click" 1508 1507 hx-target="#layout" 1509 1508 hx-swap="afterbegin" 1510 - class="cursor-pointer relative sm:aspect-square" 1509 + class="masonry-tile absolute cursor-pointer" 1510 + data-width={photo.aspectRatio?.width} 1511 + data-height={photo.aspectRatio?.height} 1511 1512 > 1512 1513 {isLoggedIn && isCreator 1513 1514 ? <AltTextButton galleryUri={gallery.uri} cid={photo.cid} /> ··· 1515 1516 <img 1516 1517 src={photo.fullsize} 1517 1518 alt={photo.alt} 1518 - class="sm:absolute sm:inset-0 w-full h-full sm:object-contain" 1519 + class="w-full h-full object-cover" 1519 1520 /> 1520 1521 {!isCreator && photo.alt 1521 1522 ? ( 1522 - <div class="absolute bg-zinc-950 dark:bg-zinc-900 bottom-2 right-2 sm:bottom-0 sm:right-0 text-xs text-white font-semibold py-[1px] px-[3px]"> 1523 + <div class="absolute bg-zinc-950 dark:bg-zinc-900 bottom-1 right-1 sm:bottom-1 sm:right-1 text-xs text-white font-semibold py-[1px] px-[3px]"> 1523 1524 ALT 1524 1525 </div> 1525 1526 ) ··· 1685 1686 }: Readonly<{ galleryUri: string; cid: string }>) { 1686 1687 return ( 1687 1688 <div 1688 - class="bg-zinc-950 dark:bg-zinc-900 py-[1px] px-[3px] absolute top-2 left-2 sm:top-0 sm:left-0 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10" 1689 + class="bg-zinc-950 dark:bg-zinc-900 py-[1px] px-[3px] absolute top-1 left-1 sm:top-1 sm:left-1 cursor-pointer flex items-center justify-center text-xs text-white font-semibold z-10" 1689 1690 hx-get={`/dialogs/image-alt?galleryUri=${galleryUri}&imageCid=${cid}`} 1690 1691 hx-trigger="click" 1691 1692 hx-target="#layout" ··· 1785 1786 rows={4} 1786 1787 defaultValue={photo.alt} 1787 1788 placeholder="Alt text" 1789 + autoFocus 1788 1790 class="dark:bg-zinc-800 dark:text-white" 1789 1791 /> 1790 1792 </div>
+84
static/masonry.js
··· 1 + // deno-lint-ignore-file 2 + 3 + let masonryObserverInitialized = false; 4 + 5 + function computeMasonry() { 6 + const container = document.getElementById("masonry-container"); 7 + if (!container) return; 8 + 9 + const spacing = 12; 10 + const containerWidth = container.offsetWidth; 11 + 12 + if (containerWidth === 0) { 13 + requestAnimationFrame(computeMasonry); 14 + return; 15 + } 16 + 17 + const columns = containerWidth < 640 ? 1 : 3; 18 + 19 + const columnWidth = (containerWidth + spacing) / columns - spacing; 20 + const columnHeights = new Array(columns).fill(0); 21 + const tiles = container.querySelectorAll(".masonry-tile"); 22 + 23 + tiles.forEach((tile) => { 24 + const imgW = parseFloat(tile.dataset.width); 25 + const imgH = parseFloat(tile.dataset.height); 26 + if (!imgW || !imgH) return; 27 + 28 + const aspectRatio = imgH / imgW; 29 + const renderedHeight = aspectRatio * columnWidth; 30 + 31 + let shortestIndex = 0; 32 + for (let i = 1; i < columns; i++) { 33 + if (columnHeights[i] < columnHeights[shortestIndex]) { 34 + shortestIndex = i; 35 + } 36 + } 37 + 38 + const left = (columnWidth + spacing) * shortestIndex; 39 + const top = columnHeights[shortestIndex]; 40 + 41 + Object.assign(tile.style, { 42 + position: "absolute", 43 + width: `${columnWidth}px`, 44 + height: `${renderedHeight}px`, 45 + left: `${left}px`, 46 + top: `${top}px`, 47 + }); 48 + 49 + columnHeights[shortestIndex] = top + renderedHeight + spacing; 50 + }); 51 + 52 + container.style.height = `${Math.max(...columnHeights)}px`; 53 + } 54 + 55 + function observeMasonry() { 56 + if (masonryObserverInitialized) return; 57 + masonryObserverInitialized = true; 58 + 59 + const container = document.getElementById("masonry-container"); 60 + if (!container) return; 61 + 62 + // Observe parent resize 63 + if (typeof ResizeObserver !== "undefined") { 64 + const resizeObserver = new ResizeObserver(() => computeMasonry()); 65 + if (container.parentElement) { 66 + resizeObserver.observe(container.parentElement); 67 + } 68 + } 69 + 70 + // Observe inner content changes (tiles being added/removed) 71 + const mutationObserver = new MutationObserver(() => { 72 + computeMasonry(); 73 + }); 74 + 75 + mutationObserver.observe(container, { 76 + childList: true, 77 + subtree: true, 78 + }); 79 + } 80 + 81 + document.addEventListener("DOMContentLoaded", () => { 82 + computeMasonry(); 83 + observeMasonry(); 84 + });
+44 -37
static/styles.css
··· 208 208 .inset-0 { 209 209 inset: calc(var(--spacing) * 0); 210 210 } 211 - .top-0 { 212 - top: calc(var(--spacing) * 0); 211 + .top-1 { 212 + top: calc(var(--spacing) * 1); 213 213 } 214 214 .top-2 { 215 215 top: calc(var(--spacing) * 2); ··· 217 217 .right-0 { 218 218 right: calc(var(--spacing) * 0); 219 219 } 220 + .right-1 { 221 + right: calc(var(--spacing) * 1); 222 + } 220 223 .right-2 { 221 224 right: calc(var(--spacing) * 2); 222 225 } 223 226 .bottom-0 { 224 227 bottom: calc(var(--spacing) * 0); 228 + } 229 + .bottom-1 { 230 + bottom: calc(var(--spacing) * 1); 225 231 } 226 232 .bottom-2 { 227 233 bottom: calc(var(--spacing) * 2); ··· 232 238 .left-0 { 233 239 left: calc(var(--spacing) * 0); 234 240 } 235 - .left-2 { 236 - left: calc(var(--spacing) * 2); 241 + .left-1 { 242 + left: calc(var(--spacing) * 1); 237 243 } 238 244 .z-10 { 239 245 z-index: 10; ··· 244 250 .z-30 { 245 251 z-index: 30; 246 252 } 253 + .container { 254 + width: 100%; 255 + @media (width >= 40rem) { 256 + max-width: 40rem; 257 + } 258 + @media (width >= 48rem) { 259 + max-width: 48rem; 260 + } 261 + @media (width >= 64rem) { 262 + max-width: 64rem; 263 + } 264 + @media (width >= 80rem) { 265 + max-width: 80rem; 266 + } 267 + @media (width >= 96rem) { 268 + max-width: 96rem; 269 + } 270 + } 247 271 .mx-auto { 248 272 margin-inline: auto; 249 273 } ··· 293 317 .size-16 { 294 318 width: calc(var(--spacing) * 16); 295 319 height: calc(var(--spacing) * 16); 320 + } 321 + .h-0 { 322 + height: calc(var(--spacing) * 0); 296 323 } 297 324 .h-1\/2 { 298 325 height: calc(1/2 * 100%); ··· 351 378 .cursor-pointer { 352 379 cursor: pointer; 353 380 } 381 + .resize { 382 + resize: both; 383 + } 354 384 .grid-cols-1 { 355 385 grid-template-columns: repeat(1, minmax(0, 1fr)); 356 386 } ··· 406 436 border-style: var(--tw-border-style); 407 437 border-width: 1px; 408 438 } 409 - .border-slate-900 { 410 - border-color: var(--color-slate-900); 439 + .border-zinc-900 { 440 + border-color: var(--color-zinc-900); 411 441 } 412 442 .bg-black { 413 443 background-color: var(--color-black); ··· 417 447 @supports (color: color-mix(in lab, red, red)) { 418 448 background-color: color-mix(in oklab, var(--color-black) 80%, transparent); 419 449 } 420 - } 421 - .bg-slate-800 { 422 - background-color: var(--color-slate-800); 423 450 } 424 451 .bg-zinc-100 { 425 452 background-color: var(--color-zinc-100); ··· 549 576 opacity: 50%; 550 577 } 551 578 } 552 - .sm\:absolute { 553 - @media (width >= 40rem) { 554 - position: absolute; 555 - } 556 - } 557 - .sm\:inset-0 { 558 - @media (width >= 40rem) { 559 - inset: calc(var(--spacing) * 0); 560 - } 561 - } 562 - .sm\:top-0 { 579 + .sm\:top-1 { 563 580 @media (width >= 40rem) { 564 - top: calc(var(--spacing) * 0); 565 - } 566 - } 567 - .sm\:right-0 { 568 - @media (width >= 40rem) { 569 - right: calc(var(--spacing) * 0); 581 + top: calc(var(--spacing) * 1); 570 582 } 571 583 } 572 - .sm\:bottom-0 { 584 + .sm\:right-1 { 573 585 @media (width >= 40rem) { 574 - bottom: calc(var(--spacing) * 0); 586 + right: calc(var(--spacing) * 1); 575 587 } 576 588 } 577 - .sm\:left-0 { 589 + .sm\:bottom-1 { 578 590 @media (width >= 40rem) { 579 - left: calc(var(--spacing) * 0); 591 + bottom: calc(var(--spacing) * 1); 580 592 } 581 593 } 582 - .sm\:aspect-square { 594 + .sm\:left-1 { 583 595 @media (width >= 40rem) { 584 - aspect-ratio: 1 / 1; 596 + left: calc(var(--spacing) * 1); 585 597 } 586 598 } 587 599 .sm\:h-screen { ··· 617 629 .sm\:justify-between { 618 630 @media (width >= 40rem) { 619 631 justify-content: space-between; 620 - } 621 - } 622 - .sm\:object-contain { 623 - @media (width >= 40rem) { 624 - object-fit: contain; 625 632 } 626 633 } 627 634 .sm\:px-0 {