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

add justified gallery layout, add toggle to switch between layouts

Changed files
+223 -24
static
+2 -10
input.css
··· 1 @import "tailwindcss"; 2 3 - .htmx-request.htmx-indicator { 4 - display: inline; 5 - } 6 - .htmx-indicator { 7 - display: none; 8 - } 9 - .htmx-request #submit-button { 10 - opacity: 0.5; 11 - pointer-events: none; 12 - }
··· 1 @import "tailwindcss"; 2 3 + /* use to test light mode */ 4 + /* @custom-variant dark (&:where(.dark, .dark *)); */
+121 -2
main.tsx
··· 59 const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL"); 60 61 let cssContentHash: string = ""; 62 63 bff({ 64 appName: "Grain Social", ··· 80 cssContentHash = Array.from(new Uint8Array(hashBuffer)) 81 .map((b) => b.toString(16).padStart(2, "0")) 82 .join(""); 83 }, 84 onError: (err) => { 85 if (err instanceof UnauthorizedError) { ··· 1059 href="https://unpkg.com/@fortawesome/fontawesome-free@6.7.2/css/all.min.css" 1060 preload 1061 /> 1062 - {scripts?.map((file) => <script key={file} src={`/static/${file}`} />)} 1063 </head> 1064 <body class="h-full w-full dark:bg-zinc-950 dark:text-white"> 1065 <Layout id="layout" class="border-zinc-200 dark:border-zinc-800"> ··· 1675 ) 1676 : null} 1677 </div> 1678 <div 1679 id="masonry-container" 1680 class="h-0 overflow-hidden relative mx-auto w-full" 1681 - _="on load or htmx:afterSettle call computeMasonry()" 1682 > 1683 {gallery.items?.filter(isPhotoView)?.length 1684 ? gallery?.items
··· 59 const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL"); 60 61 let cssContentHash: string = ""; 62 + const staticJsFiles = new Map<string, string>(); 63 64 bff({ 65 appName: "Grain Social", ··· 81 cssContentHash = Array.from(new Uint8Array(hashBuffer)) 82 .map((b) => b.toString(16).padStart(2, "0")) 83 .join(""); 84 + for (const entry of Deno.readDirSync(join(Deno.cwd(), "static"))) { 85 + if (entry.isFile && entry.name.endsWith(".js")) { 86 + const fileContent = await Deno.readFile( 87 + join(Deno.cwd(), "static", entry.name), 88 + ); 89 + const hashBuffer = await crypto.subtle.digest("SHA-256", fileContent); 90 + const hash = Array.from(new Uint8Array(hashBuffer)) 91 + .map((b) => b.toString(16).padStart(2, "0")) 92 + .join(""); 93 + staticJsFiles.set(entry.name, hash); 94 + } 95 + } 96 }, 97 onError: (err) => { 98 if (err instanceof UnauthorizedError) { ··· 1072 href="https://unpkg.com/@fortawesome/fontawesome-free@6.7.2/css/all.min.css" 1073 preload 1074 /> 1075 + {scripts?.map((file) => ( 1076 + <script 1077 + key={file} 1078 + src={`/static/${file}?${staticJsFiles.get(file)}`} 1079 + /> 1080 + ))} 1081 </head> 1082 <body class="h-full w-full dark:bg-zinc-950 dark:text-white"> 1083 <Layout id="layout" class="border-zinc-200 dark:border-zinc-800"> ··· 1693 ) 1694 : null} 1695 </div> 1696 + <div class="flex justify-end mb-2"> 1697 + <Button 1698 + id="justified-button" 1699 + variant="primary" 1700 + class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 1701 + _="on click call toggleLayout('justified') 1702 + set @data-selected to 'true' 1703 + set #masonry-button's @data-selected to 'false'" 1704 + > 1705 + <svg 1706 + width="24" 1707 + height="24" 1708 + viewBox="0 0 24 24" 1709 + xmlns="http://www.w3.org/2000/svg" 1710 + > 1711 + <rect x="2" y="2" width="8" height="6" fill="currentColor" rx="1" /> 1712 + <rect 1713 + x="12" 1714 + y="2" 1715 + width="10" 1716 + height="6" 1717 + fill="currentColor" 1718 + rx="1" 1719 + /> 1720 + <rect 1721 + x="2" 1722 + y="10" 1723 + width="6" 1724 + height="6" 1725 + fill="currentColor" 1726 + rx="1" 1727 + /> 1728 + <rect 1729 + x="10" 1730 + y="10" 1731 + width="12" 1732 + height="6" 1733 + fill="currentColor" 1734 + rx="1" 1735 + /> 1736 + <rect 1737 + x="2" 1738 + y="18" 1739 + width="20" 1740 + height="4" 1741 + fill="currentColor" 1742 + rx="1" 1743 + /> 1744 + </svg> 1745 + </Button> 1746 + <Button 1747 + id="masonry-button" 1748 + variant="primary" 1749 + data-selected="false" 1750 + class="flex justify-center w-full sm:w-fit bg-zinc-100 dark:bg-zinc-800 border-zinc-100 dark:border-zinc-800 data-[selected=false]:bg-transparent data-[selected=false]:border-transparent text-zinc-950 dark:text-zinc-50" 1751 + _="on click call toggleLayout('masonry') 1752 + set @data-selected to 'true' 1753 + set #justified-button's @data-selected to 'false'" 1754 + > 1755 + <svg 1756 + width="24" 1757 + height="24" 1758 + viewBox="0 0 24 24" 1759 + xmlns="http://www.w3.org/2000/svg" 1760 + > 1761 + <rect x="2" y="2" width="8" height="8" fill="currentColor" rx="1" /> 1762 + <rect 1763 + x="12" 1764 + y="2" 1765 + width="8" 1766 + height="4" 1767 + fill="currentColor" 1768 + rx="1" 1769 + /> 1770 + <rect 1771 + x="12" 1772 + y="8" 1773 + width="8" 1774 + height="6" 1775 + fill="currentColor" 1776 + rx="1" 1777 + /> 1778 + <rect 1779 + x="2" 1780 + y="12" 1781 + width="8" 1782 + height="8" 1783 + fill="currentColor" 1784 + rx="1" 1785 + /> 1786 + <rect 1787 + x="12" 1788 + y="16" 1789 + width="8" 1790 + height="4" 1791 + fill="currentColor" 1792 + rx="1" 1793 + /> 1794 + </svg> 1795 + </Button> 1796 + </div> 1797 <div 1798 id="masonry-container" 1799 class="h-0 overflow-hidden relative mx-auto w-full" 1800 + _="on load or htmx:afterSettle call computeLayout()" 1801 > 1802 {gallery.items?.filter(isPhotoView)?.length 1803 ? gallery?.items
+84 -2
static/masonry.js
··· 1 // deno-lint-ignore-file 2 3 let masonryObserverInitialized = false; 4 5 function computeMasonry() { 6 const container = document.getElementById("masonry-container"); ··· 52 container.style.height = `${Math.max(...columnHeights)}px`; 53 } 54 55 function observeMasonry() { 56 if (masonryObserverInitialized) return; 57 masonryObserverInitialized = true; ··· 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 } ··· 69 70 // Observe inner content changes (tiles being added/removed) 71 const mutationObserver = new MutationObserver(() => { 72 - computeMasonry(); 73 }); 74 75 mutationObserver.observe(container, {
··· 1 // deno-lint-ignore-file 2 3 let masonryObserverInitialized = false; 4 + let layoutMode = "justified"; 5 + 6 + function computeLayout() { 7 + if (layoutMode === "masonry") { 8 + computeMasonry(); 9 + } else { 10 + computeJustified(); 11 + } 12 + } 13 + 14 + function toggleLayout(layout = "justified") { 15 + layoutMode = layout; 16 + computeLayout(); 17 + } 18 19 function computeMasonry() { 20 const container = document.getElementById("masonry-container"); ··· 66 container.style.height = `${Math.max(...columnHeights)}px`; 67 } 68 69 + function computeJustified() { 70 + const container = document.getElementById("masonry-container"); 71 + if (!container) return; 72 + 73 + const spacing = 8; 74 + const containerWidth = container.offsetWidth; 75 + 76 + if (containerWidth === 0) { 77 + requestAnimationFrame(computeJustified); 78 + return; 79 + } 80 + 81 + const tiles = Array.from(container.querySelectorAll(".masonry-tile")); 82 + let currentRow = []; 83 + let rowAspectRatioSum = 0; 84 + let yOffset = 0; 85 + 86 + // Clear all styles before layout 87 + tiles.forEach((tile) => { 88 + Object.assign(tile.style, { 89 + position: "absolute", 90 + left: "0px", 91 + top: "0px", 92 + width: "auto", 93 + height: "auto", 94 + }); 95 + }); 96 + 97 + for (let i = 0; i < tiles.length; i++) { 98 + const tile = tiles[i]; 99 + const imgW = parseFloat(tile.dataset.width); 100 + const imgH = parseFloat(tile.dataset.height); 101 + if (!imgW || !imgH) continue; 102 + 103 + const aspectRatio = imgW / imgH; 104 + currentRow.push({ tile, aspectRatio, imgW, imgH }); 105 + rowAspectRatioSum += aspectRatio; 106 + 107 + // Estimate if row is "full" enough 108 + const estimatedRowHeight = 109 + (containerWidth - (currentRow.length - 1) * spacing) / rowAspectRatioSum; 110 + 111 + // If height is reasonable or we're at the end, render the row 112 + if (estimatedRowHeight < 300 || i === tiles.length - 1) { 113 + let xOffset = 0; 114 + 115 + for (const item of currentRow) { 116 + const width = estimatedRowHeight * item.aspectRatio; 117 + Object.assign(item.tile.style, { 118 + position: "absolute", 119 + top: `${yOffset}px`, 120 + left: `${xOffset}px`, 121 + width: `${width}px`, 122 + height: `${estimatedRowHeight}px`, 123 + }); 124 + xOffset += width + spacing; 125 + } 126 + 127 + yOffset += estimatedRowHeight + spacing; 128 + currentRow = []; 129 + rowAspectRatioSum = 0; 130 + } 131 + } 132 + 133 + container.style.position = "relative"; 134 + container.style.height = `${yOffset}px`; 135 + } 136 + 137 function observeMasonry() { 138 if (masonryObserverInitialized) return; 139 masonryObserverInitialized = true; ··· 143 144 // Observe parent resize 145 if (typeof ResizeObserver !== "undefined") { 146 + const resizeObserver = new ResizeObserver(() => computeLayout()); 147 if (container.parentElement) { 148 resizeObserver.observe(container.parentElement); 149 } ··· 151 152 // Observe inner content changes (tiles being added/removed) 153 const mutationObserver = new MutationObserver(() => { 154 + computeLayout(); 155 }); 156 157 mutationObserver.observe(container, {
+16 -10
static/styles.css
··· 414 .justify-center { 415 justify-content: center; 416 } 417 .gap-2 { 418 gap: calc(var(--spacing) * 2); 419 } ··· 462 .border-b { 463 border-bottom-style: var(--tw-border-style); 464 border-bottom-width: 1px; 465 } 466 .border-zinc-200 { 467 border-color: var(--color-zinc-200); ··· 610 box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 611 } 612 } 613 .data-\[state\=pending\]\:opacity-50 { 614 &[data-state="pending"] { 615 opacity: 50%; ··· 720 color: var(--color-zinc-500); 721 } 722 } 723 - } 724 - .htmx-request.htmx-indicator { 725 - display: inline; 726 - } 727 - .htmx-indicator { 728 - display: none; 729 - } 730 - .htmx-request #submit-button { 731 - opacity: 0.5; 732 - pointer-events: none; 733 } 734 @property --tw-space-y-reverse { 735 syntax: "*";
··· 414 .justify-center { 415 justify-content: center; 416 } 417 + .justify-end { 418 + justify-content: flex-end; 419 + } 420 .gap-2 { 421 gap: calc(var(--spacing) * 2); 422 } ··· 465 .border-b { 466 border-bottom-style: var(--tw-border-style); 467 border-bottom-width: 1px; 468 + } 469 + .border-zinc-100 { 470 + border-color: var(--color-zinc-100); 471 } 472 .border-zinc-200 { 473 border-color: var(--color-zinc-200); ··· 616 box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 617 } 618 } 619 + .data-\[selected\=false\]\:border-transparent { 620 + &[data-selected="false"] { 621 + border-color: transparent; 622 + } 623 + } 624 + .data-\[selected\=false\]\:bg-transparent { 625 + &[data-selected="false"] { 626 + background-color: transparent; 627 + } 628 + } 629 .data-\[state\=pending\]\:opacity-50 { 630 &[data-state="pending"] { 631 opacity: 50%; ··· 736 color: var(--color-zinc-500); 737 } 738 } 739 } 740 @property --tw-space-y-reverse { 741 syntax: "*";