The source code for our eny.social landing page, which is mirrored in a different repository as part of the CI setup. eny.social
social-network eny local-first
2
fork

Configure Feed

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

feat(RememberWhen): replace static images with cycling animated GIFs

+113 -26
+113 -26
app/components/RememberWhen.tsx
··· 1 1 "use client"; 2 2 3 - import Image from "next/image"; 3 + /* eslint-disable @next/next/no-img-element */ 4 + import { useEffect, useMemo, useState } from "react"; 4 5 import SectionIntroLabel from "./ui/SectionIntroLabel"; 5 6 import FadeIn from "./ui/FadeIn"; 6 7 8 + // Active GIF duration per slot (must match `items` order). 9 + // Adjust these values to control how long each GIF stays active. 10 + const GIF_INTERVALS_MS = [6250, 6350, 6700] as const; 11 + 7 12 export default function RememberWhen() { 13 + const items = useMemo( 14 + () => [ 15 + { 16 + src: "/images/gif/hug.gif", 17 + mask: "mask-1", 18 + position: "bottom" as const, 19 + }, 20 + { 21 + src: "/images/gif/dance.gif", 22 + mask: "mask-2", 23 + position: "center" as const, 24 + }, 25 + { 26 + src: "/images/gif/hands-up.gif", 27 + mask: "mask-3", 28 + position: "top" as const, 29 + }, 30 + ], 31 + [], 32 + ); 33 + 34 + const [activeIndex, setActiveIndex] = useState(0); 35 + const [staticFrameByIndex, setStaticFrameByIndex] = useState< 36 + Record<number, string> 37 + >({}); 38 + 39 + useEffect(() => { 40 + const durationMs = 41 + GIF_INTERVALS_MS[activeIndex] ?? GIF_INTERVALS_MS[0] ?? 6000; 42 + 43 + const timeoutId = window.setTimeout(() => { 44 + setActiveIndex((prev) => (prev + 1) % items.length); 45 + }, durationMs); 46 + 47 + return () => window.clearTimeout(timeoutId); 48 + }, [activeIndex, items.length]); 49 + 50 + useEffect(() => { 51 + let cancelled = false; 52 + 53 + const captureFirstFrame = async (src: string, index: number) => { 54 + const img = new window.Image(); 55 + img.crossOrigin = "anonymous"; 56 + 57 + await new Promise<void>((resolve) => { 58 + img.onload = () => resolve(); 59 + img.onerror = () => resolve(); 60 + img.src = src; 61 + }); 62 + 63 + if (cancelled) return; 64 + if (!img.naturalWidth || !img.naturalHeight) return; 65 + 66 + const canvas = document.createElement("canvas"); 67 + canvas.width = img.naturalWidth; 68 + canvas.height = img.naturalHeight; 69 + const ctx = canvas.getContext("2d"); 70 + if (!ctx) return; 71 + 72 + // Capture immediately after load: in most browsers the first frame is available. 73 + ctx.drawImage(img, 0, 0); 74 + 75 + try { 76 + const dataUrl = canvas.toDataURL("image/png"); 77 + if (cancelled) return; 78 + setStaticFrameByIndex((prev) => ({ ...prev, [index]: dataUrl })); 79 + } catch { 80 + // Canvas is tainted (CORS) or similar. Just don't set a static frame. 81 + } 82 + }; 83 + 84 + items.forEach((it, i) => { 85 + void captureFirstFrame(it.src, i); 86 + }); 87 + 88 + return () => { 89 + cancelled = true; 90 + }; 91 + }, [items]); 92 + 8 93 return ( 9 94 <section className="relative px-6 py-24"> 10 95 {/* SVG clip path definitions — all shapes are 350x400 */} ··· 48 133 49 134 {/* Three masked photos */} 50 135 <div className="mt-12 flex items-center justify-center gap-6 md:gap-10"> 51 - {[ 52 - { 53 - src: "/images/pexels-shvets-production-7194971.jpg", 54 - mask: "mask-1", 55 - position: "bottom" as const, 56 - }, 57 - { 58 - src: "/images/pexels-didsss-7664407.jpg", 59 - mask: "mask-2", 60 - position: "center" as const, 61 - }, 62 - { 63 - src: "/images/pexels-shvets-production-7533377 1.png", 64 - mask: "mask-3", 65 - position: "top" as const, 66 - }, 67 - ].map((item, i) => ( 136 + {items.map((item, i) => ( 68 137 <FadeIn key={i} delay={200 + i * 150}> 69 138 <div 70 139 className="relative w-[28vw] h-[32vw] max-w-[263px] max-h-[300px] overflow-hidden" 71 140 style={{ clipPath: `url(#${item.mask})` }} 72 141 > 73 - <Image 74 - src={item.src} 75 - alt="" 76 - width={526} 77 - height={600} 78 - className="h-full w-full object-cover" 79 - style={{ objectPosition: item.position }} 80 - /> 142 + {activeIndex === i ? ( 143 + <img 144 + src={item.src} 145 + alt="" 146 + className="h-full w-full object-cover" 147 + style={{ objectPosition: item.position }} 148 + draggable={false} 149 + /> 150 + ) : staticFrameByIndex[i] ? ( 151 + <img 152 + src={staticFrameByIndex[i]} 153 + alt="" 154 + className="h-full w-full object-cover" 155 + style={{ objectPosition: item.position }} 156 + draggable={false} 157 + /> 158 + ) : ( 159 + // Hide the GIF while the static first-frame snapshot loads. 160 + <img 161 + src={item.src} 162 + alt="" 163 + className="h-full w-full object-cover" 164 + style={{ objectPosition: item.position, opacity: 0 }} 165 + draggable={false} 166 + /> 167 + )} 81 168 </div> 82 169 </FadeIn> 83 170 ))}
public/images/gif/dance.gif

This is a binary file and will not be displayed.

public/images/gif/hands-up.gif

This is a binary file and will not be displayed.

public/images/gif/hug.gif

This is a binary file and will not be displayed.