grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
57
fork

Configure Feed

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

add justified gallery layout, add toggle to switch between layouts

+223 -24
+2 -10
input.css
··· 1 1 @import "tailwindcss"; 2 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 - } 3 + /* use to test light mode */ 4 + /* @custom-variant dark (&:where(.dark, .dark *)); */
+121 -2
main.tsx
··· 59 59 const GOATCOUNTER_URL = Deno.env.get("GOATCOUNTER_URL"); 60 60 61 61 let cssContentHash: string = ""; 62 + const staticJsFiles = new Map<string, string>(); 62 63 63 64 bff({ 64 65 appName: "Grain Social", ··· 80 81 cssContentHash = Array.from(new Uint8Array(hashBuffer)) 81 82 .map((b) => b.toString(16).padStart(2, "0")) 82 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 + } 83 96 }, 84 97 onError: (err) => { 85 98 if (err instanceof UnauthorizedError) { ··· 1059 1072 href="https://unpkg.com/@fortawesome/fontawesome-free@6.7.2/css/all.min.css" 1060 1073 preload 1061 1074 /> 1062 - {scripts?.map((file) => <script key={file} src={`/static/${file}`} />)} 1075 + {scripts?.map((file) => ( 1076 + <script 1077 + key={file} 1078 + src={`/static/${file}?${staticJsFiles.get(file)}`} 1079 + /> 1080 + ))} 1063 1081 </head> 1064 1082 <body class="h-full w-full dark:bg-zinc-950 dark:text-white"> 1065 1083 <Layout id="layout" class="border-zinc-200 dark:border-zinc-800"> ··· 1675 1693 ) 1676 1694 : null} 1677 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> 1678 1797 <div 1679 1798 id="masonry-container" 1680 1799 class="h-0 overflow-hidden relative mx-auto w-full" 1681 - _="on load or htmx:afterSettle call computeMasonry()" 1800 + _="on load or htmx:afterSettle call computeLayout()" 1682 1801 > 1683 1802 {gallery.items?.filter(isPhotoView)?.length 1684 1803 ? gallery?.items
+84 -2
static/masonry.js
··· 1 1 // deno-lint-ignore-file 2 2 3 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 + } 4 18 5 19 function computeMasonry() { 6 20 const container = document.getElementById("masonry-container"); ··· 52 66 container.style.height = `${Math.max(...columnHeights)}px`; 53 67 } 54 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 + 55 137 function observeMasonry() { 56 138 if (masonryObserverInitialized) return; 57 139 masonryObserverInitialized = true; ··· 61 143 62 144 // Observe parent resize 63 145 if (typeof ResizeObserver !== "undefined") { 64 - const resizeObserver = new ResizeObserver(() => computeMasonry()); 146 + const resizeObserver = new ResizeObserver(() => computeLayout()); 65 147 if (container.parentElement) { 66 148 resizeObserver.observe(container.parentElement); 67 149 } ··· 69 151 70 152 // Observe inner content changes (tiles being added/removed) 71 153 const mutationObserver = new MutationObserver(() => { 72 - computeMasonry(); 154 + computeLayout(); 73 155 }); 74 156 75 157 mutationObserver.observe(container, {
+16 -10
static/styles.css
··· 414 414 .justify-center { 415 415 justify-content: center; 416 416 } 417 + .justify-end { 418 + justify-content: flex-end; 419 + } 417 420 .gap-2 { 418 421 gap: calc(var(--spacing) * 2); 419 422 } ··· 462 465 .border-b { 463 466 border-bottom-style: var(--tw-border-style); 464 467 border-bottom-width: 1px; 468 + } 469 + .border-zinc-100 { 470 + border-color: var(--color-zinc-100); 465 471 } 466 472 .border-zinc-200 { 467 473 border-color: var(--color-zinc-200); ··· 610 616 box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); 611 617 } 612 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 + } 613 629 .data-\[state\=pending\]\:opacity-50 { 614 630 &[data-state="pending"] { 615 631 opacity: 50%; ··· 720 736 color: var(--color-zinc-500); 721 737 } 722 738 } 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 739 } 734 740 @property --tw-space-y-reverse { 735 741 syntax: "*";